diff --git a/converse.js b/converse.js index 47c4123dd..7f8812f1a 100644 --- a/converse.js +++ b/converse.js @@ -3652,6 +3652,7 @@ converse.controlboxtoggle.showControlBox(); } else if (subscription === 'both' || subscription === 'to') { this.$el.addClass('current-xmpp-contact'); + this.$el.addClass(subscription); this.$el.html(converse.templates.roster_item( _.extend(item.toJSON(), { 'desc_status': STATUSES[chat_status||'offline'], @@ -4255,9 +4256,9 @@ }, initialize: function () { - this.registerRosterHandler(); - this.registerRosterXHandler(); - this.registerPresenceHandler(); + 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); @@ -4267,6 +4268,15 @@ 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()+')'); @@ -4457,7 +4467,7 @@ if (_.has(contact.changed, 'subscription')) { if (contact.changed.subscription == 'from') { this.addContactToGroup(contact, HEADER_PENDING_CONTACTS); - } else if (contact.get('subscription') === 'both') { + } else if (_.contains(['both', 'to'], contact.get('subscription'))) { this.addExistingContact(contact); } } @@ -5526,6 +5536,7 @@ this.roster.off().reset(); // Removes roster contacts } if (this.rosterview) { + this.rosterview.unregisterHandlers(); this.rosterview.model.off().reset(); // Removes roster groups this.rosterview.undelegateEvents().remove(); } diff --git a/spec/protocol.js b/spec/protocol.js index d88ceb949..3af3fda63 100644 --- a/spec/protocol.js +++ b/spec/protocol.js @@ -8,8 +8,12 @@ } ); } (this, function ($, mock, test_utils) { + "use strict"; var Strophe = converse_api.env.Strophe; var $iq = converse_api.env.$iq; + var $pres = converse_api.env.$pres; + // See: + // https://xmpp.org/rfcs/rfc3921.html describe("The Protocol", $.proxy(function (mock, test_utils) { describe("Integration of Roster Items and Presence Subscriptions", $.proxy(function (mock, test_utils) { @@ -54,123 +58,259 @@ /* The process by which a user subscribes to a contact, including * the interaction between roster items and subscription states. */ - var stanzaID; - var sentStanza; - var panel = this.chatboxviews.get('controlbox').contactspanel; - spyOn(panel, "addContactFromForm").andCallThrough(); - spyOn(converse.roster, "addAndSubscribe").andCallThrough(); - spyOn(converse.roster, "addContact").andCallThrough(); - spyOn(converse.roster, "sendContactAddIQ").andCallThrough(); - var sendIQ = this.connection.sendIQ; - spyOn(this.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { - sentStanza = iq; - stanzaID = sendIQ.bind(this)(iq, callback, errback); - }); - panel.delegateEvents(); // Rebind all events so that our spy gets called + var contact, stanza, sentStanza, iq_id; + runs($.proxy(function () { + var panel = this.chatboxviews.get('controlbox').contactspanel; + spyOn(panel, "addContactFromForm").andCallThrough(); + spyOn(this.roster, "addAndSubscribe").andCallThrough(); + spyOn(this.roster, "addContact").andCallThrough(); + spyOn(this.roster, "sendContactAddIQ").andCallThrough(); + spyOn(this, "getVCard").andCallThrough(); + var sendIQ = this.connection.sendIQ; + spyOn(this.connection, 'sendIQ').andCallFake(function (iq, callback, errback) { + sentStanza = iq; + iq_id = sendIQ.bind(this)(iq, callback, errback); + }); + panel.delegateEvents(); // Rebind all events so that our spy gets called - /* Add a new contact through the UI */ - var $form = panel.$('form.add-xmpp-contact'); - expect($form.is(":visible")).toBeFalsy(); - // Click the "Add a contact" link. - panel.$('.toggle-xmpp-contact-form').click(); - // Check that the $form appears - expect($form.is(":visible")).toBeTruthy(); - // Fill in the form and submit - $form.find('input').val('contact@example.org'); - $form.submit(); + /* Add a new contact through the UI */ + var $form = panel.$('form.add-xmpp-contact'); + expect($form.is(":visible")).toBeFalsy(); + // Click the "Add a contact" link. + panel.$('.toggle-xmpp-contact-form').click(); + // Check that the $form appears + expect($form.is(":visible")).toBeTruthy(); + // Fill in the form and submit + $form.find('input').val('contact@example.org'); + $form.submit(); - /* In preparation for being able to render the contact in the - * user's client interface and for the server to keep track of the - * subscription, the user's client SHOULD perform a "roster set" - * for the new roster item. - */ - expect(panel.addContactFromForm).toHaveBeenCalled(); - expect(converse.roster.addAndSubscribe).toHaveBeenCalled(); - expect(converse.roster.addContact).toHaveBeenCalled(); - // The form should not be visible anymore. - expect($form.is(":visible")).toBeFalsy(); + /* In preparation for being able to render the contact in the + * user's client interface and for the server to keep track of the + * subscription, the user's client SHOULD perform a "roster set" + * for the new roster item. + */ + expect(panel.addContactFromForm).toHaveBeenCalled(); + expect(converse.roster.addAndSubscribe).toHaveBeenCalled(); + expect(converse.roster.addContact).toHaveBeenCalled(); + // The form should not be visible anymore. + expect($form.is(":visible")).toBeFalsy(); - /* This request consists of sending an IQ - * stanza of type='set' containing a element qualified by - * the 'jabber:iq:roster' namespace, which in turn contains an - * element that defines the new roster item; the - * element MUST possess a 'jid' attribute, MAY possess a 'name' - * attribute, MUST NOT possess a 'subscription' attribute, and MAY - * contain one or more child elements: - * - * - * - * - * MyBuddies - * - * - * - */ - expect(converse.roster.sendContactAddIQ).toHaveBeenCalled(); - expect(sentStanza.toLocaleString()).toBe( - ""+ - ""+ - ""+ - ""+ - "" - ); - /* As a result, the user's server (1) MUST initiate a roster push - * for the new roster item to all available resources associated - * with this user that have requested the roster, setting the - * 'subscription' attribute to a value of "none"; and (2) MUST - * reply to the sending resource with an IQ result indicating the - * success of the roster set: - * - * - * - * - * MyBuddies - * - * - * - */ - var contact; - var send = converse.connection.send; - var create = converse.roster.create; - spyOn(converse.roster, 'create').andCallFake(function () { - contact = create.apply(converse.roster, arguments); - spyOn(contact, 'subscribe').andCallThrough(); + /* This request consists of sending an IQ + * stanza of type='set' containing a element qualified by + * the 'jabber:iq:roster' namespace, which in turn contains an + * element that defines the new roster item; the + * element MUST possess a 'jid' attribute, MAY possess a 'name' + * attribute, MUST NOT possess a 'subscription' attribute, and MAY + * contain one or more child elements: + * + * + * + * + * MyBuddies + * + * + * + */ + expect(converse.roster.sendContactAddIQ).toHaveBeenCalled(); + expect(sentStanza.toLocaleString()).toBe( + ""+ + ""+ + ""+ + ""+ + "" + ); + /* As a result, the user's server (1) MUST initiate a roster push + * for the new roster item to all available resources associated + * with this user that have requested the roster, setting the + * 'subscription' attribute to a value of "none"; and (2) MUST + * reply to the sending resource with an IQ result indicating the + * success of the roster set: + * + * + * + * + * MyBuddies + * + * + * + */ + var create = converse.roster.create; spyOn(converse.connection, 'send').andCallFake(function (stanza) { sentStanza = stanza; }); - return contact; - }); - var iq = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'}) - .c('item', { - 'jid': 'contact@example.org', - 'subscription': 'none', - 'name': 'contact@example.org'}); - this.connection._dataRecv(test_utils.createRequest(iq)); - /* - * - */ - iq = $iq({'type': 'result', 'id':stanzaID}); - this.connection._dataRecv(test_utils.createRequest(iq)); + spyOn(converse.roster, 'create').andCallFake(function () { + contact = create.apply(converse.roster, arguments); + spyOn(contact, 'subscribe').andCallThrough(); + return contact; + }); + stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'}) + .c('item', { + 'jid': 'contact@example.org', + 'subscription': 'none', + 'name': 'contact@example.org'}); + this.connection._dataRecv(test_utils.createRequest(stanza)); + /* + * + */ + stanza = $iq({'type': 'result', 'id':iq_id}); + this.connection._dataRecv(test_utils.createRequest(stanza)); - // A contact should now have been created - expect(this.roster.get('contact@example.org') instanceof converse.RosterContact).toBeTruthy(); - expect(contact.get('jid')).toBe('contact@example.org'); + // A contact should now have been created + expect(this.roster.get('contact@example.org') instanceof converse.RosterContact).toBeTruthy(); + expect(contact.get('jid')).toBe('contact@example.org'); + expect(this.getVCard).toHaveBeenCalled(); - /* To subscribe to the contact's presence information, - * the user's client MUST send a presence stanza of - * type='subscribe' to the contact: - * - * - */ - expect(contact.subscribe).toHaveBeenCalled(); - expect(sentStanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec) - "" - ); + /* To subscribe to the contact's presence information, + * the user's client MUST send a presence stanza of + * type='subscribe' to the contact: + * + * + */ + expect(contact.subscribe).toHaveBeenCalled(); + expect(sentStanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec) + "" + ); + /* As a result, the user's server MUST initiate a second roster + * push to all of the user's available resources that have + * requested the roster, setting the contact to the pending + * sub-state of the 'none' subscription state; this pending + * sub-state is denoted by the inclusion of the ask='subscribe' + * attribute in the roster item: + * + * + * + * + * MyBuddies + * + * + * + */ + spyOn(converse.roster, "updateContact").andCallThrough(); + stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'}) + .c('item', { + 'jid': 'contact@example.org', + 'subscription': 'none', + 'ask': 'subscribe', + 'name': 'contact@example.org'}); + this.connection._dataRecv(test_utils.createRequest(stanza)); + expect(converse.roster.updateContact).toHaveBeenCalled(); + }, this)); + waits(50); // Needed, due to debounce + runs($.proxy(function () { + // Check that the user is now properly shown as a pending + // contact in the roster. + var $header = $('a:contains("Pending contacts")'); + expect($header.length).toBe(1); + expect($header.is(":visible")).toBeTruthy(); + var $contacts = $header.parent().nextUntil('dt', 'dd'); + expect($contacts.length).toBe(1); + + spyOn(contact, "acknowledgeSubscription").andCallThrough(); + /* Here we assume the "happy path" that the contact + * approves the subscription request + * + * + */ + stanza = $pres({ + 'to': converse.bare_jid, + 'from': 'contact@example.org', + 'type': 'subscribed' + }); + sentStanza = ""; // Reset + this.connection._dataRecv(test_utils.createRequest(stanza)); + /* Upon receiving the presence stanza of type "subscribed", + * the user SHOULD acknowledge receipt of that + * subscription state notification by sending a presence + * stanza of type "subscribe". + */ + expect(contact.acknowledgeSubscription).toHaveBeenCalled(); + expect(sentStanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec) + "" + ); + + /* The user's server MUST initiate a roster push to all of the user's + * available resources that have requested the roster, + * containing an updated roster item for the contact with + * the 'subscription' attribute set to a value of "to"; + * + * + * + * + * MyBuddies + * + * + * + */ + iq_id = converse.connection.getUniqueId('roster'); + stanza = $iq({'type': 'set', 'id': iq_id}) + .c('query', {'xmlns': 'jabber:iq:roster'}) + .c('item', { + 'jid': 'contact@example.org', + 'subscription': 'to', + 'name': 'contact@example.org'}); + this.connection._dataRecv(test_utils.createRequest(stanza)); + // Check that the IQ set was acknowledged. + expect(sentStanza.toLocaleString()).toBe( // Strophe adds the xmlns attr (although not in spec) + "" + ); + expect(converse.roster.updateContact).toHaveBeenCalled(); + + // The contact should now be visible as an existing + // contact (but still offline). + $header = $('a:contains("My contacts")'); + expect($header.length).toBe(1); + expect($header.is(":visible")).toBeTruthy(); + $contacts = $header.parent().nextUntil('dt', 'dd'); + expect($contacts.length).toBe(1); + // Check that it has the right classes and text + expect($contacts.hasClass('to')).toBeTruthy(); + expect($contacts.hasClass('from')).toBeFalsy(); + expect($contacts.hasClass('current-xmpp-contact')).toBeTruthy(); + expect($contacts.text().trim()).toBe('Contact'); + expect(contact.get('chat_status')).toBe('offline'); + + /* + */ + stanza = $pres({'to': converse.bare_jid, 'from': 'contact@example.org/resource'}); + this.connection._dataRecv(test_utils.createRequest(stanza)); + // Now the contact should also be online. + expect(contact.get('chat_status')).toBe('online'); + + /* Section 8.3. Creating a Mutual Subscription + * + * If the contact wants to create a mutual subscription, + * the contact MUST send a subscription request to the + * user. + * + * + */ + // TODO + + /* The user's client MUST send a presence stanza of type + * "subscribed" to the contact in order to approve the + * subscription request. + * + * + */ + // TODO + }, this)); }, converse)); it("Alternate Flow: Contact Declines Subscription Request", $.proxy(function () {