From df3bcad0b39bc0a13c952548df3f31568c7ee831 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 13 Mar 2016 16:40:52 +0000 Subject: [PATCH] Move roster view code into a separate plugin --- converse.js | 1 + src/converse-controlbox.js | 716 +------------------------------- src/converse-core.js | 13 +- src/converse-notification.js | 1 - src/converse-rosterview.js | 764 +++++++++++++++++++++++++++++++++++ 5 files changed, 773 insertions(+), 722 deletions(-) create mode 100644 src/converse-rosterview.js diff --git a/converse.js b/converse.js index c32c2e0fa..cff77aa91 100644 --- a/converse.js +++ b/converse.js @@ -46,6 +46,7 @@ require.config({ // Converse "converse-api": "src/converse-api", "converse-chatview": "src/converse-chatview", + "converse-rosterview": "src/converse-rosterview", "converse-controlbox": "src/converse-controlbox", "converse-core": "src/converse-core", "converse-headline": "src/converse-headline", diff --git a/src/converse-controlbox.js b/src/converse-controlbox.js index 4b93a5b0e..7a8b12ca1 100644 --- a/src/converse-controlbox.js +++ b/src/converse-controlbox.js @@ -10,15 +10,14 @@ define("converse-controlbox", [ "converse-core", "converse-api", + // TODO: remove the next two dependencies "converse-rosterview", - // TODO: remove this dependency "converse-chatview" ], factory); }(this, function (converse, converse_api) { "use strict"; // Strophe methods for building stanzas var Strophe = converse_api.env.Strophe, - $iq = converse_api.env.$iq, b64_sha1 = converse_api.env.b64_sha1, utils = converse_api.env.utils; // Other necessary globals @@ -177,26 +176,7 @@ show_controlbox_by_default: false, }); - var STATUSES = { - 'dnd': __('This contact is busy'), - 'online': __('This contact is online'), - 'offline': __('This contact is offline'), - 'unavailable': __('This contact is unavailable'), - 'xa': __('This contact is away for an extended period'), - 'away': __('This contact is away') - }; - var DESC_GROUP_TOGGLE = __('Click to hide these contacts'); var LABEL_CONTACTS = __('Contacts'); - var LABEL_GROUPS = __('Groups'); - var HEADER_CURRENT_CONTACTS = __('My contacts'); - var HEADER_PENDING_CONTACTS = __('Pending contacts'); - var HEADER_REQUESTING_CONTACTS = __('Contact requests'); - var HEADER_UNGROUPED = __('Ungrouped'); - var HEADER_WEIGHTS = {}; - HEADER_WEIGHTS[HEADER_CURRENT_CONTACTS] = 0; - HEADER_WEIGHTS[HEADER_UNGROUPED] = 1; - HEADER_WEIGHTS[HEADER_REQUESTING_CONTACTS] = 2; - HEADER_WEIGHTS[HEADER_PENDING_CONTACTS] = 3; converse.addControlBox = function () { return converse.chatboxes.add({ @@ -693,700 +673,6 @@ }); - converse.RosterContactView = Backbone.View.extend({ - tagName: 'dd', - - events: { - "click .accept-xmpp-request": "acceptRequest", - "click .decline-xmpp-request": "declineRequest", - "click .open-chat": "openChat", - "click .remove-xmpp-contact": "removeContact" - }, - - initialize: function () { - this.model.on("change", this.render, this); - this.model.on("remove", this.remove, this); - this.model.on("destroy", this.remove, this); - this.model.on("open", this.openChat, this); - }, - - render: function () { - if (!this.model.showInRoster()) { - this.$el.hide(); - return this; - } else if (this.$el[0].style.display === "none") { - this.$el.show(); - } - var item = this.model, - ask = item.get('ask'), - chat_status = item.get('chat_status'), - requesting = item.get('requesting'), - subscription = item.get('subscription'); - - var classes_to_remove = [ - 'current-xmpp-contact', - 'pending-xmpp-contact', - 'requesting-xmpp-contact' - ].concat(_.keys(STATUSES)); - - _.each(classes_to_remove, - function (cls) { - if (this.el.className.indexOf(cls) !== -1) { - this.$el.removeClass(cls); - } - }, this); - this.$el.addClass(chat_status).data('status', chat_status); - - if ((ask === 'subscribe') || (subscription === 'from')) { - /* ask === 'subscribe' - * Means we have asked to subscribe to them. - * - * subscription === 'from' - * They are subscribed to use, but not vice versa. - * We assume that there is a pending subscription - * from us to them (otherwise we're in a state not - * supported by converse.js). - * - * So in both cases the user is a "pending" contact. - */ - this.$el.addClass('pending-xmpp-contact'); - this.$el.html(converse.templates.pending_contact( - _.extend(item.toJSON(), { - 'desc_remove': __('Click to remove this contact'), - 'allow_chat_pending_contacts': converse.allow_chat_pending_contacts - }) - )); - } else if (requesting === true) { - this.$el.addClass('requesting-xmpp-contact'); - this.$el.html(converse.templates.requesting_contact( - _.extend(item.toJSON(), { - 'desc_accept': __("Click to accept this contact request"), - 'desc_decline': __("Click to decline this contact request"), - 'allow_chat_pending_contacts': converse.allow_chat_pending_contacts - }) - )); - converse.controlboxtoggle.showControlBox(); - } else if (subscription === 'both' || subscription === 'to') { - this.$el.addClass('current-xmpp-contact'); - this.$el.removeClass(_.without(['both', 'to'], subscription)[0]).addClass(subscription); - this.$el.html(converse.templates.roster_item( - _.extend(item.toJSON(), { - 'desc_status': STATUSES[chat_status||'offline'], - 'desc_chat': __('Click to chat with this contact'), - 'desc_remove': __('Click to remove this contact'), - 'title_fullname': __('Name'), - 'allow_contact_removal': converse.allow_contact_removal - }) - )); - } - return this; - }, - - openChat: function (ev) { - if (ev && ev.preventDefault) { ev.preventDefault(); } - return converse.chatboxviews.showChat(this.model.attributes); - }, - - removeContact: function (ev) { - if (ev && ev.preventDefault) { ev.preventDefault(); } - if (!converse.allow_contact_removal) { return; } - var result = confirm(__("Are you sure you want to remove this contact?")); - if (result === true) { - var iq = $iq({type: 'set'}) - .c('query', {xmlns: Strophe.NS.ROSTER}) - .c('item', {jid: this.model.get('jid'), subscription: "remove"}); - converse.connection.sendIQ(iq, - function (iq) { - this.model.destroy(); - this.remove(); - }.bind(this), - function (err) { - alert(__("Sorry, there was an error while trying to remove "+name+" as a contact.")); - converse.log(err); - } - ); - } - }, - - acceptRequest: function (ev) { - if (ev && ev.preventDefault) { ev.preventDefault(); } - converse.roster.sendContactAddIQ( - this.model.get('jid'), - this.model.get('fullname'), - [], - function () { this.model.authorize().subscribe(); }.bind(this) - ); - }, - - declineRequest: function (ev) { - if (ev && ev.preventDefault) { ev.preventDefault(); } - var result = confirm(__("Are you sure you want to decline this contact request?")); - if (result === true) { - this.model.unauthorize().destroy(); - } - return this; - } - }); - - - converse.RosterGroup = Backbone.Model.extend({ - initialize: function (attributes, options) { - this.set(_.extend({ - description: DESC_GROUP_TOGGLE, - state: converse.OPENED - }, attributes)); - // Collection of contacts belonging to this group. - this.contacts = new converse.RosterContacts(); - } - }); - - - converse.RosterGroupView = Backbone.Overview.extend({ - tagName: 'dt', - className: 'roster-group', - events: { - "click a.group-toggle": "toggle" - }, - - initialize: function () { - this.model.contacts.on("add", this.addContact, this); - this.model.contacts.on("change:subscription", this.onContactSubscriptionChange, this); - this.model.contacts.on("change:requesting", this.onContactRequestChange, this); - this.model.contacts.on("change:chat_status", function (contact) { - // This might be optimized by instead of first sorting, - // finding the correct position in positionContact - this.model.contacts.sort(); - this.positionContact(contact).render(); - }, this); - this.model.contacts.on("destroy", this.onRemove, this); - this.model.contacts.on("remove", this.onRemove, this); - converse.roster.on('change:groups', this.onContactGroupChange, this); - }, - - render: function () { - this.$el.attr('data-group', this.model.get('name')); - this.$el.html( - $(converse.templates.group_header({ - label_group: this.model.get('name'), - desc_group_toggle: this.model.get('description'), - toggle_state: this.model.get('state') - })) - ); - return this; - }, - - addContact: function (contact) { - var view = new converse.RosterContactView({model: contact}); - this.add(contact.get('id'), view); - view = this.positionContact(contact).render(); - if (contact.showInRoster()) { - 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(); } - } else { - if (this.$el[0].style.display !== "block") { this.show(); } - } - } - }, - - positionContact: function (contact) { - /* Place the contact's DOM element in the correct alphabetical - * position amongst the other contacts in this group. - */ - var view = this.get(contact.get('id')); - var index = this.model.contacts.indexOf(contact); - view.$el.detach(); - if (index === 0) { - this.$el.after(view.$el); - } else if (index === (this.model.contacts.length-1)) { - this.$el.nextUntil('dt').last().after(view.$el); - } else { - this.$el.nextUntil('dt').eq(index).before(view.$el); - } - return view; - }, - - show: function () { - this.$el.show(); - _.each(this.getAll(), function (contactView) { - if (contactView.model.showInRoster()) { - contactView.$el.show(); - } - }); - }, - - hide: function () { - this.$el.nextUntil('dt').addBack().hide(); - }, - - filter: function (q) { - /* 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 - * group must be filtered out as well. - */ - var matches; - 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(); - } - }.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 - this.hide(); - } else { - _.each(matches, function (item) { - this.get(item.get('id')).$el.hide(); - }.bind(this)); - _.each(this.model.contacts.reject(utils.contains.not('fullname', q)), function (item) { - this.get(item.get('id')).$el.show(); - }.bind(this)); - this.showIfNecessary(); - } - } - }, - - showIfNecessary: function () { - if (!this.$el.is(':visible') && this.model.contacts.length > 0) { - this.$el.show(); - } - }, - - toggle: function (ev) { - if (ev && ev.preventDefault) { ev.preventDefault(); } - var $el = $(ev.target); - if ($el.hasClass("icon-opened")) { - this.$el.nextUntil('dt').slideUp(); - this.model.save({state: converse.CLOSED}); - $el.removeClass("icon-opened").addClass("icon-closed"); - } else { - $el.removeClass("icon-closed").addClass("icon-opened"); - this.model.save({state: converse.OPENED}); - this.filter( - converse.rosterview.$('.roster-filter').val(), - converse.rosterview.$('.filter-type').val() - ); - } - }, - - onContactGroupChange: function (contact) { - var in_this_group = _.contains(contact.get('groups'), this.model.get('name')); - var cid = contact.get('id'); - var in_this_overview = !this.get(cid); - if (in_this_group && !in_this_overview) { - this.model.contacts.remove(cid); - } else if (!in_this_group && in_this_overview) { - this.addContact(contact); - } - }, - - onContactSubscriptionChange: function (contact) { - if ((this.model.get('name') === HEADER_PENDING_CONTACTS) && contact.get('subscription') !== 'from') { - this.model.contacts.remove(contact.get('id')); - } - }, - - onContactRequestChange: function (contact) { - if ((this.model.get('name') === HEADER_REQUESTING_CONTACTS) && !contact.get('requesting')) { - this.model.contacts.remove(contact.get('id')); - } - }, - - onRemove: function (contact) { - this.remove(contact.get('id')); - if (this.model.contacts.length === 0) { - this.$el.hide(); - } - } - }); - - - converse.RosterGroups = Backbone.Collection.extend({ - model: converse.RosterGroup, - comparator: function (a, b) { - /* Groups are sorted alphabetically, ignoring case. - * However, Ungrouped, Requesting Contacts and Pending Contacts - * appear last and in that order. */ - a = a.get('name'); - b = b.get('name'); - var special_groups = _.keys(HEADER_WEIGHTS); - var a_is_special = _.contains(special_groups, a); - var b_is_special = _.contains(special_groups, b); - if (!a_is_special && !b_is_special ) { - return a.toLowerCase() < b.toLowerCase() ? -1 : (a.toLowerCase() > b.toLowerCase() ? 1 : 0); - } else if (a_is_special && b_is_special) { - return HEADER_WEIGHTS[a] < HEADER_WEIGHTS[b] ? -1 : (HEADER_WEIGHTS[a] > HEADER_WEIGHTS[b] ? 1 : 0); - } else if (!a_is_special && b_is_special) { - return (b === HEADER_CURRENT_CONTACTS) ? 1 : -1; - } else if (a_is_special && !b_is_special) { - return (a === HEADER_CURRENT_CONTACTS) ? -1 : 1; - } - } - }); - - 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(); - this.rosterx_handler_ref = this.registerRosterXHandler(); - this.presence_ref = this.registerPresenceHandler(); - converse.roster.on("add", this.onContactAdd, this); - converse.roster.on('change', this.onContactChange, this); - converse.roster.on("destroy", this.update, this); - converse.roster.on("remove", this.update, this); - this.model.on("add", this.onGroupAdd, this); - this.model.on("reset", this.reset, this); - this.$roster = $(''); - }, - - unregisterHandlers: function () { - converse.connection.deleteHandler(this.roster_handler_ref); - delete this.roster_handler_ref; - converse.connection.deleteHandler(this.rosterx_handler_ref); - delete this.rosterx_handler_ref; - converse.connection.deleteHandler(this.presence_ref); - delete this.presence_ref; - }, - - update: _.debounce(function () { - var $count = $('#online-count'); - $count.text('('+converse.roster.getNumOnlineContacts()+')'); - if (!$count.is(':visible')) { - $count.show(); - } - 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'); - } - return this; - }, - - fetch: function () { - this.model.fetch({ - silent: true, // We use the success handler to handle groups that were added, - // we need to first have all groups before positionFetchedGroups - // will work properly. - success: function (collection, resp, options) { - if (collection.length !== 0) { - this.positionFetchedGroups(collection, resp, options); - } - converse.roster.fetch({ - add: true, - success: function (collection) { - if (collection.length === 0) { - /* We don't have any roster contacts stored in sessionStorage, - * so lets fetch the roster from the XMPP server. We pass in - * 'sendPresence' as callback method, because after initially - * fetching the roster we are ready to receive presence - * updates from our contacts. - */ - converse.roster.fetchFromServer(function () { - converse.xmppstatus.sendPresence(); - }); - } else if (converse.send_initial_presence) { - /* We're not going to fetch the roster again because we have - * it already cached in sessionStorage, but we still need to - * send out a presence stanza because this is a new session. - * See: https://github.com/jcbrand/converse.js/issues/536 - */ - converse.xmppstatus.sendPresence(); - } - } - }); - }.bind(this) - }); - 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) { - query = query.toLowerCase(); - if (type === 'groups') { - _.each(this.getAll(), function (view, idx) { - if (view.model.get('name').toLowerCase().indexOf(query.toLowerCase()) === -1) { - view.hide(); - } else if (view.model.contacts.length > 0) { - view.show(); - } - }); - } else { - _.each(this.getAll(), function (view) { - view.filter(query, type); - }); - } - }, - - 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(); - this.$roster = $(''); - this.render().update(); - return this; - }, - - registerRosterHandler: function () { - converse.connection.addHandler( - converse.roster.onRosterPush.bind(converse.roster), - Strophe.NS.ROSTER, 'iq', "set" - ); - }, - - registerRosterXHandler: function () { - var t = 0; - converse.connection.addHandler( - function (msg) { - window.setTimeout( - function () { - converse.connection.flush(); - converse.roster.subscribeToSuggestedItems.bind(converse.roster)(msg); - }, - t - ); - t += $(msg).find('item').length*250; - return true; - }, - Strophe.NS.ROSTERX, 'message', null - ); - }, - - registerPresenceHandler: function () { - converse.connection.addHandler( - function (presence) { - converse.roster.presenceHandler(presence); - return true; - }.bind(this), null, 'presence', null); - }, - - onGroupAdd: function (group) { - var view = new converse.RosterGroupView({model: group}); - this.add(group.get('name'), view.render()); - this.positionGroup(view); - }, - - onContactAdd: function (contact) { - this.addRosterContact(contact).update(); - if (!contact.get('vcard_updated')) { - // This will update the vcard, which triggers a change - // request which will rerender the roster contact. - converse.getVCard(contact.get('jid')); - } - }, - - onContactChange: function (contact) { - this.updateChatBox(contact).update(); - if (_.has(contact.changed, 'subscription')) { - if (contact.changed.subscription === 'from') { - this.addContactToGroup(contact, HEADER_PENDING_CONTACTS); - } else if (_.contains(['both', 'to'], contact.get('subscription'))) { - this.addExistingContact(contact); - } - } - if (_.has(contact.changed, 'ask') && contact.changed.ask === 'subscribe') { - this.addContactToGroup(contact, HEADER_PENDING_CONTACTS); - } - if (_.has(contact.changed, 'subscription') && contact.changed.requesting === 'true') { - this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS); - } - this.liveFilter(); - }, - - updateChatBox: function (contact) { - var chatbox = converse.chatboxes.get(contact.get('jid')), - changes = {}; - if (!chatbox) { - return this; - } - if (_.has(contact.changed, 'chat_status')) { - changes.chat_status = contact.get('chat_status'); - } - if (_.has(contact.changed, 'status')) { - changes.status = contact.get('status'); - } - chatbox.save(changes); - return this; - }, - - positionFetchedGroups: function (model, resp, options) { - /* Instead of throwing an add event for each group - * fetched, we wait until they're all fetched and then - * we position them. - * Works around the problem of positionGroup not - * working when all groups besides the one being - * positioned aren't already in inserted into the - * roster DOM element. - */ - model.sort(); - model.each(function (group, idx) { - var view = this.get(group.get('name')); - if (!view) { - view = new converse.RosterGroupView({model: group}); - this.add(group.get('name'), view.render()); - } - if (idx === 0) { - this.$roster.append(view.$el); - } else { - this.appendGroup(view); - } - }.bind(this)); - }, - - positionGroup: function (view) { - /* Place the group's DOM element in the correct alphabetical - * position amongst the other groups in the roster. - */ - var $groups = this.$roster.find('.roster-group'), - index = $groups.length ? this.model.indexOf(view.model) : 0; - if (index === 0) { - this.$roster.prepend(view.$el); - } else if (index === (this.model.length-1)) { - this.appendGroup(view); - } else { - $($groups.eq(index)).before(view.$el); - } - return this; - }, - - appendGroup: function (view) { - /* Add the group at the bottom of the roster - */ - var $last = this.$roster.find('.roster-group').last(); - var $siblings = $last.siblings('dd'); - if ($siblings.length > 0) { - $siblings.last().after(view.$el); - } else { - $last.after(view.$el); - } - return this; - }, - - getGroup: function (name) { - /* Returns the group as specified by name. - * Creates the group if it doesn't exist. - */ - var view = this.get(name); - if (view) { - return view.model; - } - return this.model.create({name: name, id: b64_sha1(name)}); - }, - - addContactToGroup: function (contact, name) { - this.getGroup(name).contacts.add(contact); - }, - - addExistingContact: function (contact) { - var groups; - if (converse.roster_groups) { - groups = contact.get('groups'); - if (groups.length === 0) { - groups = [HEADER_UNGROUPED]; - } - } else { - groups = [HEADER_CURRENT_CONTACTS]; - } - _.each(groups, _.bind(this.addContactToGroup, this, contact)); - }, - - addRosterContact: function (contact) { - if (contact.get('subscription') === 'both' || contact.get('subscription') === 'to') { - this.addExistingContact(contact); - } else { - if ((contact.get('ask') === 'subscribe') || (contact.get('subscription') === 'from')) { - this.addContactToGroup(contact, HEADER_PENDING_CONTACTS); - } else if (contact.get('requesting') === true) { - this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS); - } - } - return this; - } - }); - - converse.ControlBoxToggle = Backbone.View.extend({ tagName: 'a', className: 'toggle-controlbox', diff --git a/src/converse-core.js b/src/converse-core.js index eeb5dc42a..f5804cf53 100755 --- a/src/converse-core.js +++ b/src/converse-core.js @@ -761,17 +761,18 @@ }.bind(this), 200)); }; + this.afterReconnected = function () { + this.chatboxes.registerMessageHandler(); + this.xmppstatus.sendPresence(); + this.giveFeedback(__('Contacts')); + }; + this.onReconnected = function () { // We need to re-register all the event handlers on the newly // created connection. var deferred = new $.Deferred(); this.initStatus(function () { - // FIXME: leaky abstraction from RosterView - this.rosterview.registerRosterXHandler(); - this.rosterview.registerPresenceHandler(); - this.chatboxes.registerMessageHandler(); - this.xmppstatus.sendPresence(); - this.giveFeedback(__('Contacts')); + this.afterReconnected(); deferred.resolve(); }.bind(this)); return deferred.promise(); diff --git a/src/converse-notification.js b/src/converse-notification.js index 6907f64dd..34054f9fd 100644 --- a/src/converse-notification.js +++ b/src/converse-notification.js @@ -36,7 +36,6 @@ notification_icon: '/logo/conversejs.png' }); - converse.isOnlyChatStateNotification = function ($msg) { // See XEP-0085 Chat State Notification return ( diff --git a/src/converse-rosterview.js b/src/converse-rosterview.js new file mode 100644 index 000000000..08bd46c75 --- /dev/null +++ b/src/converse-rosterview.js @@ -0,0 +1,764 @@ +// Converse.js (A browser based XMPP chat client) +// http://conversejs.org +// +// Copyright (c) 2012-2016, Jan-Carel Brand +// Licensed under the Mozilla Public License (MPLv2) +// +/*global Backbone, define */ + +(function (root, factory) { + define("converse-rosterview", ["converse-core", "converse-api"], factory); +}(this, function (converse, converse_api) { + "use strict"; + var $ = converse_api.env.jQuery, + utils = converse_api.env.utils, + Strophe = converse_api.env.Strophe, + $iq = converse_api.env.$iq, + b64_sha1 = converse_api.env.b64_sha1, + _ = converse_api.env._, + __ = utils.__.bind(converse); + + + converse_api.plugins.add('rosterview', { + + overrides: { + // Overrides mentioned here will be picked up by converse.js's + // plugin architecture they will replace existing methods on the + // relevant objects or classes. + // + // New functions which don't exist yet can also be added. + + afterReconnected: function () { + this.rosterview.registerRosterXHandler(); + this.rosterview.registerPresenceHandler(); + this._super.afterReconnected.apply(this, arguments); + } + }, + + + initialize: function () { + /* The initialize function gets called as soon as the plugin is + * loaded by converse.js's plugin machinery. + */ + this.updateSettings({ + show_toolbar: true, + }); + + var STATUSES = { + 'dnd': __('This contact is busy'), + 'online': __('This contact is online'), + 'offline': __('This contact is offline'), + 'unavailable': __('This contact is unavailable'), + 'xa': __('This contact is away for an extended period'), + 'away': __('This contact is away') + }; + var DESC_GROUP_TOGGLE = __('Click to hide these contacts'); + var LABEL_CONTACTS = __('Contacts'); + var LABEL_GROUPS = __('Groups'); + var HEADER_CURRENT_CONTACTS = __('My contacts'); + var HEADER_PENDING_CONTACTS = __('Pending contacts'); + var HEADER_REQUESTING_CONTACTS = __('Contact requests'); + var HEADER_UNGROUPED = __('Ungrouped'); + var HEADER_WEIGHTS = {}; + HEADER_WEIGHTS[HEADER_CURRENT_CONTACTS] = 0; + HEADER_WEIGHTS[HEADER_UNGROUPED] = 1; + HEADER_WEIGHTS[HEADER_REQUESTING_CONTACTS] = 2; + HEADER_WEIGHTS[HEADER_PENDING_CONTACTS] = 3; + + + 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(); + this.rosterx_handler_ref = this.registerRosterXHandler(); + this.presence_ref = this.registerPresenceHandler(); + converse.roster.on("add", this.onContactAdd, this); + converse.roster.on('change', this.onContactChange, this); + converse.roster.on("destroy", this.update, this); + converse.roster.on("remove", this.update, this); + this.model.on("add", this.onGroupAdd, this); + this.model.on("reset", this.reset, this); + this.$roster = $(''); + }, + + unregisterHandlers: function () { + converse.connection.deleteHandler(this.roster_handler_ref); + delete this.roster_handler_ref; + converse.connection.deleteHandler(this.rosterx_handler_ref); + delete this.rosterx_handler_ref; + converse.connection.deleteHandler(this.presence_ref); + delete this.presence_ref; + }, + + update: _.debounce(function () { + var $count = $('#online-count'); + $count.text('('+converse.roster.getNumOnlineContacts()+')'); + if (!$count.is(':visible')) { + $count.show(); + } + 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'); + } + return this; + }, + + fetch: function () { + this.model.fetch({ + silent: true, // We use the success handler to handle groups that were added, + // we need to first have all groups before positionFetchedGroups + // will work properly. + success: function (collection, resp, options) { + if (collection.length !== 0) { + this.positionFetchedGroups(collection, resp, options); + } + converse.roster.fetch({ + add: true, + success: function (collection) { + if (collection.length === 0) { + /* We don't have any roster contacts stored in sessionStorage, + * so lets fetch the roster from the XMPP server. We pass in + * 'sendPresence' as callback method, because after initially + * fetching the roster we are ready to receive presence + * updates from our contacts. + */ + converse.roster.fetchFromServer(function () { + converse.xmppstatus.sendPresence(); + }); + } else if (converse.send_initial_presence) { + /* We're not going to fetch the roster again because we have + * it already cached in sessionStorage, but we still need to + * send out a presence stanza because this is a new session. + * See: https://github.com/jcbrand/converse.js/issues/536 + */ + converse.xmppstatus.sendPresence(); + } + } + }); + }.bind(this) + }); + 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) { + query = query.toLowerCase(); + if (type === 'groups') { + _.each(this.getAll(), function (view, idx) { + if (view.model.get('name').toLowerCase().indexOf(query.toLowerCase()) === -1) { + view.hide(); + } else if (view.model.contacts.length > 0) { + view.show(); + } + }); + } else { + _.each(this.getAll(), function (view) { + view.filter(query, type); + }); + } + }, + + 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(); + this.$roster = $(''); + this.render().update(); + return this; + }, + + registerRosterHandler: function () { + converse.connection.addHandler( + converse.roster.onRosterPush.bind(converse.roster), + Strophe.NS.ROSTER, 'iq', "set" + ); + }, + + registerRosterXHandler: function () { + var t = 0; + converse.connection.addHandler( + function (msg) { + window.setTimeout( + function () { + converse.connection.flush(); + converse.roster.subscribeToSuggestedItems.bind(converse.roster)(msg); + }, + t + ); + t += $(msg).find('item').length*250; + return true; + }, + Strophe.NS.ROSTERX, 'message', null + ); + }, + + registerPresenceHandler: function () { + converse.connection.addHandler( + function (presence) { + converse.roster.presenceHandler(presence); + return true; + }.bind(this), null, 'presence', null); + }, + + onGroupAdd: function (group) { + var view = new converse.RosterGroupView({model: group}); + this.add(group.get('name'), view.render()); + this.positionGroup(view); + }, + + onContactAdd: function (contact) { + this.addRosterContact(contact).update(); + if (!contact.get('vcard_updated')) { + // This will update the vcard, which triggers a change + // request which will rerender the roster contact. + converse.getVCard(contact.get('jid')); + } + }, + + onContactChange: function (contact) { + this.updateChatBox(contact).update(); + if (_.has(contact.changed, 'subscription')) { + if (contact.changed.subscription === 'from') { + this.addContactToGroup(contact, HEADER_PENDING_CONTACTS); + } else if (_.contains(['both', 'to'], contact.get('subscription'))) { + this.addExistingContact(contact); + } + } + if (_.has(contact.changed, 'ask') && contact.changed.ask === 'subscribe') { + this.addContactToGroup(contact, HEADER_PENDING_CONTACTS); + } + if (_.has(contact.changed, 'subscription') && contact.changed.requesting === 'true') { + this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS); + } + this.liveFilter(); + }, + + updateChatBox: function (contact) { + var chatbox = converse.chatboxes.get(contact.get('jid')), + changes = {}; + if (!chatbox) { + return this; + } + if (_.has(contact.changed, 'chat_status')) { + changes.chat_status = contact.get('chat_status'); + } + if (_.has(contact.changed, 'status')) { + changes.status = contact.get('status'); + } + chatbox.save(changes); + return this; + }, + + positionFetchedGroups: function (model, resp, options) { + /* Instead of throwing an add event for each group + * fetched, we wait until they're all fetched and then + * we position them. + * Works around the problem of positionGroup not + * working when all groups besides the one being + * positioned aren't already in inserted into the + * roster DOM element. + */ + model.sort(); + model.each(function (group, idx) { + var view = this.get(group.get('name')); + if (!view) { + view = new converse.RosterGroupView({model: group}); + this.add(group.get('name'), view.render()); + } + if (idx === 0) { + this.$roster.append(view.$el); + } else { + this.appendGroup(view); + } + }.bind(this)); + }, + + positionGroup: function (view) { + /* Place the group's DOM element in the correct alphabetical + * position amongst the other groups in the roster. + */ + var $groups = this.$roster.find('.roster-group'), + index = $groups.length ? this.model.indexOf(view.model) : 0; + if (index === 0) { + this.$roster.prepend(view.$el); + } else if (index === (this.model.length-1)) { + this.appendGroup(view); + } else { + $($groups.eq(index)).before(view.$el); + } + return this; + }, + + appendGroup: function (view) { + /* Add the group at the bottom of the roster + */ + var $last = this.$roster.find('.roster-group').last(); + var $siblings = $last.siblings('dd'); + if ($siblings.length > 0) { + $siblings.last().after(view.$el); + } else { + $last.after(view.$el); + } + return this; + }, + + getGroup: function (name) { + /* Returns the group as specified by name. + * Creates the group if it doesn't exist. + */ + var view = this.get(name); + if (view) { + return view.model; + } + return this.model.create({name: name, id: b64_sha1(name)}); + }, + + addContactToGroup: function (contact, name) { + this.getGroup(name).contacts.add(contact); + }, + + addExistingContact: function (contact) { + var groups; + if (converse.roster_groups) { + groups = contact.get('groups'); + if (groups.length === 0) { + groups = [HEADER_UNGROUPED]; + } + } else { + groups = [HEADER_CURRENT_CONTACTS]; + } + _.each(groups, _.bind(this.addContactToGroup, this, contact)); + }, + + addRosterContact: function (contact) { + if (contact.get('subscription') === 'both' || contact.get('subscription') === 'to') { + this.addExistingContact(contact); + } else { + if ((contact.get('ask') === 'subscribe') || (contact.get('subscription') === 'from')) { + this.addContactToGroup(contact, HEADER_PENDING_CONTACTS); + } else if (contact.get('requesting') === true) { + this.addContactToGroup(contact, HEADER_REQUESTING_CONTACTS); + } + } + return this; + } + }); + + + converse.RosterContactView = Backbone.View.extend({ + tagName: 'dd', + + events: { + "click .accept-xmpp-request": "acceptRequest", + "click .decline-xmpp-request": "declineRequest", + "click .open-chat": "openChat", + "click .remove-xmpp-contact": "removeContact" + }, + + initialize: function () { + this.model.on("change", this.render, this); + this.model.on("remove", this.remove, this); + this.model.on("destroy", this.remove, this); + this.model.on("open", this.openChat, this); + }, + + render: function () { + if (!this.model.showInRoster()) { + this.$el.hide(); + return this; + } else if (this.$el[0].style.display === "none") { + this.$el.show(); + } + var item = this.model, + ask = item.get('ask'), + chat_status = item.get('chat_status'), + requesting = item.get('requesting'), + subscription = item.get('subscription'); + + var classes_to_remove = [ + 'current-xmpp-contact', + 'pending-xmpp-contact', + 'requesting-xmpp-contact' + ].concat(_.keys(STATUSES)); + + _.each(classes_to_remove, + function (cls) { + if (this.el.className.indexOf(cls) !== -1) { + this.$el.removeClass(cls); + } + }, this); + this.$el.addClass(chat_status).data('status', chat_status); + + if ((ask === 'subscribe') || (subscription === 'from')) { + /* ask === 'subscribe' + * Means we have asked to subscribe to them. + * + * subscription === 'from' + * They are subscribed to use, but not vice versa. + * We assume that there is a pending subscription + * from us to them (otherwise we're in a state not + * supported by converse.js). + * + * So in both cases the user is a "pending" contact. + */ + this.$el.addClass('pending-xmpp-contact'); + this.$el.html(converse.templates.pending_contact( + _.extend(item.toJSON(), { + 'desc_remove': __('Click to remove this contact'), + 'allow_chat_pending_contacts': converse.allow_chat_pending_contacts + }) + )); + } else if (requesting === true) { + this.$el.addClass('requesting-xmpp-contact'); + this.$el.html(converse.templates.requesting_contact( + _.extend(item.toJSON(), { + 'desc_accept': __("Click to accept this contact request"), + 'desc_decline': __("Click to decline this contact request"), + 'allow_chat_pending_contacts': converse.allow_chat_pending_contacts + }) + )); + converse.controlboxtoggle.showControlBox(); + } else if (subscription === 'both' || subscription === 'to') { + this.$el.addClass('current-xmpp-contact'); + this.$el.removeClass(_.without(['both', 'to'], subscription)[0]).addClass(subscription); + this.$el.html(converse.templates.roster_item( + _.extend(item.toJSON(), { + 'desc_status': STATUSES[chat_status||'offline'], + 'desc_chat': __('Click to chat with this contact'), + 'desc_remove': __('Click to remove this contact'), + 'title_fullname': __('Name'), + 'allow_contact_removal': converse.allow_contact_removal + }) + )); + } + return this; + }, + + openChat: function (ev) { + if (ev && ev.preventDefault) { ev.preventDefault(); } + return converse.chatboxviews.showChat(this.model.attributes); + }, + + removeContact: function (ev) { + if (ev && ev.preventDefault) { ev.preventDefault(); } + if (!converse.allow_contact_removal) { return; } + var result = confirm(__("Are you sure you want to remove this contact?")); + if (result === true) { + var iq = $iq({type: 'set'}) + .c('query', {xmlns: Strophe.NS.ROSTER}) + .c('item', {jid: this.model.get('jid'), subscription: "remove"}); + converse.connection.sendIQ(iq, + function (iq) { + this.model.destroy(); + this.remove(); + }.bind(this), + function (err) { + alert(__("Sorry, there was an error while trying to remove "+name+" as a contact.")); + converse.log(err); + } + ); + } + }, + + acceptRequest: function (ev) { + if (ev && ev.preventDefault) { ev.preventDefault(); } + converse.roster.sendContactAddIQ( + this.model.get('jid'), + this.model.get('fullname'), + [], + function () { this.model.authorize().subscribe(); }.bind(this) + ); + }, + + declineRequest: function (ev) { + if (ev && ev.preventDefault) { ev.preventDefault(); } + var result = confirm(__("Are you sure you want to decline this contact request?")); + if (result === true) { + this.model.unauthorize().destroy(); + } + return this; + } + }); + + + converse.RosterGroup = Backbone.Model.extend({ + initialize: function (attributes, options) { + this.set(_.extend({ + description: DESC_GROUP_TOGGLE, + state: converse.OPENED + }, attributes)); + // Collection of contacts belonging to this group. + this.contacts = new converse.RosterContacts(); + } + }); + + + converse.RosterGroupView = Backbone.Overview.extend({ + tagName: 'dt', + className: 'roster-group', + events: { + "click a.group-toggle": "toggle" + }, + + initialize: function () { + this.model.contacts.on("add", this.addContact, this); + this.model.contacts.on("change:subscription", this.onContactSubscriptionChange, this); + this.model.contacts.on("change:requesting", this.onContactRequestChange, this); + this.model.contacts.on("change:chat_status", function (contact) { + // This might be optimized by instead of first sorting, + // finding the correct position in positionContact + this.model.contacts.sort(); + this.positionContact(contact).render(); + }, this); + this.model.contacts.on("destroy", this.onRemove, this); + this.model.contacts.on("remove", this.onRemove, this); + converse.roster.on('change:groups', this.onContactGroupChange, this); + }, + + render: function () { + this.$el.attr('data-group', this.model.get('name')); + this.$el.html( + $(converse.templates.group_header({ + label_group: this.model.get('name'), + desc_group_toggle: this.model.get('description'), + toggle_state: this.model.get('state') + })) + ); + return this; + }, + + addContact: function (contact) { + var view = new converse.RosterContactView({model: contact}); + this.add(contact.get('id'), view); + view = this.positionContact(contact).render(); + if (contact.showInRoster()) { + 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(); } + } else { + if (this.$el[0].style.display !== "block") { this.show(); } + } + } + }, + + positionContact: function (contact) { + /* Place the contact's DOM element in the correct alphabetical + * position amongst the other contacts in this group. + */ + var view = this.get(contact.get('id')); + var index = this.model.contacts.indexOf(contact); + view.$el.detach(); + if (index === 0) { + this.$el.after(view.$el); + } else if (index === (this.model.contacts.length-1)) { + this.$el.nextUntil('dt').last().after(view.$el); + } else { + this.$el.nextUntil('dt').eq(index).before(view.$el); + } + return view; + }, + + show: function () { + this.$el.show(); + _.each(this.getAll(), function (contactView) { + if (contactView.model.showInRoster()) { + contactView.$el.show(); + } + }); + }, + + hide: function () { + this.$el.nextUntil('dt').addBack().hide(); + }, + + filter: function (q) { + /* 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 + * group must be filtered out as well. + */ + var matches; + 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(); + } + }.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 + this.hide(); + } else { + _.each(matches, function (item) { + this.get(item.get('id')).$el.hide(); + }.bind(this)); + _.each(this.model.contacts.reject(utils.contains.not('fullname', q)), function (item) { + this.get(item.get('id')).$el.show(); + }.bind(this)); + this.showIfNecessary(); + } + } + }, + + showIfNecessary: function () { + if (!this.$el.is(':visible') && this.model.contacts.length > 0) { + this.$el.show(); + } + }, + + toggle: function (ev) { + if (ev && ev.preventDefault) { ev.preventDefault(); } + var $el = $(ev.target); + if ($el.hasClass("icon-opened")) { + this.$el.nextUntil('dt').slideUp(); + this.model.save({state: converse.CLOSED}); + $el.removeClass("icon-opened").addClass("icon-closed"); + } else { + $el.removeClass("icon-closed").addClass("icon-opened"); + this.model.save({state: converse.OPENED}); + this.filter( + converse.rosterview.$('.roster-filter').val(), + converse.rosterview.$('.filter-type').val() + ); + } + }, + + onContactGroupChange: function (contact) { + var in_this_group = _.contains(contact.get('groups'), this.model.get('name')); + var cid = contact.get('id'); + var in_this_overview = !this.get(cid); + if (in_this_group && !in_this_overview) { + this.model.contacts.remove(cid); + } else if (!in_this_group && in_this_overview) { + this.addContact(contact); + } + }, + + onContactSubscriptionChange: function (contact) { + if ((this.model.get('name') === HEADER_PENDING_CONTACTS) && contact.get('subscription') !== 'from') { + this.model.contacts.remove(contact.get('id')); + } + }, + + onContactRequestChange: function (contact) { + if ((this.model.get('name') === HEADER_REQUESTING_CONTACTS) && !contact.get('requesting')) { + this.model.contacts.remove(contact.get('id')); + } + }, + + onRemove: function (contact) { + this.remove(contact.get('id')); + if (this.model.contacts.length === 0) { + this.$el.hide(); + } + } + }); + + + converse.RosterGroups = Backbone.Collection.extend({ + model: converse.RosterGroup, + comparator: function (a, b) { + /* Groups are sorted alphabetically, ignoring case. + * However, Ungrouped, Requesting Contacts and Pending Contacts + * appear last and in that order. */ + a = a.get('name'); + b = b.get('name'); + var special_groups = _.keys(HEADER_WEIGHTS); + var a_is_special = _.contains(special_groups, a); + var b_is_special = _.contains(special_groups, b); + if (!a_is_special && !b_is_special ) { + return a.toLowerCase() < b.toLowerCase() ? -1 : (a.toLowerCase() > b.toLowerCase() ? 1 : 0); + } else if (a_is_special && b_is_special) { + return HEADER_WEIGHTS[a] < HEADER_WEIGHTS[b] ? -1 : (HEADER_WEIGHTS[a] > HEADER_WEIGHTS[b] ? 1 : 0); + } else if (!a_is_special && b_is_special) { + return (b === HEADER_CURRENT_CONTACTS) ? 1 : -1; + } else if (a_is_special && !b_is_special) { + return (a === HEADER_CURRENT_CONTACTS) ? -1 : 1; + } + } + }); + } + }); +}));