diff --git a/spec/chatroom.js b/spec/chatroom.js index d879acafb..fc9005d05 100644 --- a/spec/chatroom.js +++ b/spec/chatroom.js @@ -882,7 +882,7 @@ _converse.api.rooms.open('coven@chat.shakespeare.lit', {'nick': 'some1'}); view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); - spyOn(view, 'saveAffiliationAndRole').and.callThrough(); + spyOn(view.model, 'saveAffiliationAndRole').and.callThrough(); // We pretend this is a new room, so no disco info is returned. var features_stanza = $iq({ @@ -895,13 +895,13 @@ _converse.connection._dataRecv(test_utils.createRequest(features_stanza)); /* - * - * - * - * - * - */ + * from="coven@chat.shakespeare.lit/some1"> + * + * + * + * + * + */ var presence = $pres({ to: 'dummy@localhost/_converse.js-29092160', from: 'coven@chat.shakespeare.lit/some1' @@ -913,7 +913,7 @@ }).up() .c('status', {code: '110'}); _converse.connection._dataRecv(test_utils.createRequest(presence)); - expect(view.saveAffiliationAndRole).toHaveBeenCalled(); + expect(view.model.saveAffiliationAndRole).toHaveBeenCalled(); expect($(view.el.querySelector('.toggle-chatbox-button')).is(':visible')).toBeTruthy(); test_utils.waitUntil(function () { @@ -1348,7 +1348,7 @@ // receiving the features for the room. view.model.set('open', 'true'); - spyOn(view, 'directInvite').and.callThrough(); + spyOn(view.model, 'directInvite').and.callThrough(); var $input; $(view.el).find('.chat-area').remove(); @@ -1376,7 +1376,7 @@ evt.button = 0; // For some reason awesomplete wants this $hint[0].dispatchEvent(evt); expect(window.prompt).toHaveBeenCalled(); - expect(view.directInvite).toHaveBeenCalled(); + expect(view.model.directInvite).toHaveBeenCalled(); expect(sent_stanza.toLocaleString()).toBe( ""+ @@ -2193,7 +2193,7 @@ }); var view = _converse.chatboxviews.get('lounge@localhost'); spyOn(view, 'onMessageSubmitted').and.callThrough(); - spyOn(view, 'setAffiliation').and.callThrough(); + spyOn(view.model, 'setAffiliation').and.callThrough(); spyOn(view, 'showStatusNotification').and.callThrough(); spyOn(view, 'validateRoleChangeCommand').and.callThrough(); var textarea = view.el.querySelector('.chat-textarea') @@ -2209,7 +2209,7 @@ "Error: the \"ban\" command takes two arguments, the user's nickname and optionally a reason.", true ); - expect(view.setAffiliation).not.toHaveBeenCalled(); + expect(view.model.setAffiliation).not.toHaveBeenCalled(); // Call now with the correct amount of arguments. // XXX: Calling onMessageSubmitted directly, trying // again via triggering Event doesn't work for some weird @@ -2217,7 +2217,7 @@ view.onMessageSubmitted('/ban annoyingGuy@localhost You\'re annoying'); expect(view.validateRoleChangeCommand.calls.count()).toBe(2); expect(view.showStatusNotification.calls.count()).toBe(1); - expect(view.setAffiliation).toHaveBeenCalled(); + expect(view.model.setAffiliation).toHaveBeenCalled(); // Check that the member list now gets updated expect(sent_IQ.toLocaleString()).toBe( ""+ @@ -2902,7 +2902,7 @@ var name = mock.cur_names[0]; var invitee_jid = name.replace(/ /g,'.').toLowerCase() + '@localhost'; var reason = "Please join this chat room"; - view.directInvite(invitee_jid, reason); + view.model.directInvite(invitee_jid, reason); // Check in reverse order that we requested all three lists // (member, owner and admin). @@ -3023,26 +3023,26 @@ var remove_absentees = false; var new_list = []; var old_list = []; - var delta = roomview.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + var delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); expect(delta.length).toBe(0); new_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; - delta = roomview.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); expect(delta.length).toBe(0); // When remove_absentees is false, then affiliations in the old // list which are not in the new one won't be removed. old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'}, {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; - delta = roomview.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); expect(delta.length).toBe(0); // With exclude_existing set to false, any changed affiliations // will be included in the delta (i.e. existing affiliations // are included in the comparison). old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}]; - delta = roomview.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); expect(delta.length).toBe(1); expect(delta[0].jid).toBe('wiccarocks@shakespeare.lit'); expect(delta[0].affiliation).toBe('member'); @@ -3052,12 +3052,12 @@ remove_absentees = true; old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'}, {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; - delta = roomview.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); expect(delta.length).toBe(1); expect(delta[0].jid).toBe('oldhag666@shakespeare.lit'); expect(delta[0].affiliation).toBe('none'); - delta = roomview.computeAffiliationsDelta(exclude_existing, remove_absentees, [], old_list); + delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, [], old_list); expect(delta.length).toBe(2); expect(delta[0].jid).toBe('oldhag666@shakespeare.lit'); expect(delta[0].affiliation).toBe('none'); @@ -3068,7 +3068,7 @@ // affiliation, we set 'exclude_existing' to true exclude_existing = true; old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}]; - delta = roomview.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + delta = u.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); expect(delta.length).toBe(0); done(); })); diff --git a/src/config.js b/src/config.js index e91f5a9a8..ef89f5cc0 100644 --- a/src/config.js +++ b/src/config.js @@ -29,7 +29,7 @@ require.config({ "emojione": "node_modules/emojione/lib/js/emojione", "es6-promise": "node_modules/es6-promise/dist/es6-promise.auto", "eventemitter": "node_modules/otr/build/dep/eventemitter", - "form-utils": "src/form-utils", + "form-utils": "src/utils/form", "i18n": "src/i18n", "jed": "node_modules/jed/jed", "jquery": "src/jquery-stub", @@ -37,19 +37,10 @@ require.config({ "lodash.converter": "3rdparty/lodash.fp", "lodash.fp": "src/lodash.fp", "lodash.noconflict": "src/lodash.noconflict", + "muc-utils": "src/utils/muc", "pluggable": "node_modules/pluggable.js/dist/pluggable", "polyfill": "src/polyfill", "sizzle": "node_modules/sizzle/dist/sizzle", - "strophe": "node_modules/strophe.js/strophe", - "strophe.disco": "node_modules/strophejs-plugin-disco/strophe.disco", - "strophe.ping": "node_modules/strophejs-plugin-ping/strophe.ping", - "strophe.rsm": "node_modules/strophejs-plugin-rsm/strophe.rsm", - "strophe.vcard": "node_modules/strophejs-plugin-vcard/strophe.vcard", - "text": "node_modules/text/text", - "tpl": "node_modules/lodash-template-loader/loader", - "underscore": "src/underscore-shim", - "utils": "src/utils", - "vdom-parser": "node_modules/vdom-parser/dist", "snabbdom": "node_modules/snabbdom/dist/snabbdom", "snabbdom-attributes": "node_modules/snabbdom/dist/snabbdom-attributes", "snabbdom-class": "node_modules/snabbdom/dist/snabbdom-class", @@ -57,7 +48,17 @@ require.config({ "snabbdom-eventlisteners": "node_modules/snabbdom/dist/snabbdom-eventlisteners", "snabbdom-props": "node_modules/snabbdom/dist/snabbdom-props", "snabbdom-style": "node_modules/snabbdom/dist/snabbdom-style", + "strophe": "node_modules/strophe.js/strophe", + "strophe.disco": "node_modules/strophejs-plugin-disco/strophe.disco", + "strophe.ping": "node_modules/strophejs-plugin-ping/strophe.ping", + "strophe.rsm": "node_modules/strophejs-plugin-rsm/strophe.rsm", + "strophe.vcard": "node_modules/strophejs-plugin-vcard/strophe.vcard", + "text": "node_modules/text/text", "tovnode": "node_modules/snabbdom/dist/tovnode", + "tpl": "node_modules/lodash-template-loader/loader", + "underscore": "src/underscore-shim", + "utils": "src/utils/core", + "vdom-parser": "node_modules/vdom-parser/dist", "xss": "node_modules/xss/dist/xss", "xss.noconflict": "src/xss.noconflict", diff --git a/src/converse-muc-views.js b/src/converse-muc-views.js index 0c6794e21..9b1af93a0 100644 --- a/src/converse-muc-views.js +++ b/src/converse-muc-views.js @@ -11,6 +11,7 @@ (function (root, factory) { define([ "converse-core", + "muc-utils", "emojione", "tpl!add_chatroom_modal", "tpl!chatarea", @@ -37,6 +38,7 @@ ], factory); }(this, function ( converse, + muc_utils, emojione, tpl_add_chatroom_modal, tpl_chatarea, @@ -627,269 +629,6 @@ this.insertIntoTextArea(ev.target.textContent); }, - requestMemberList (chatroom_jid, affiliation) { - /* Send an IQ stanza to the server, asking it for the - * member-list of this room. - * - * See: http://xmpp.org/extensions/xep-0045.html#modifymember - * - * Parameters: - * (String) chatroom_jid: The JID of the chatroom for - * which the member-list is being requested - * (String) affiliation: The specific member list to - * fetch. 'admin', 'owner' or 'member'. - * - * Returns: - * A promise which resolves once the list has been - * retrieved. - */ - return new Promise((resolve, reject) => { - affiliation = affiliation || 'member'; - const iq = $iq({to: chatroom_jid, type: "get"}) - .c("query", {xmlns: Strophe.NS.MUC_ADMIN}) - .c("item", {'affiliation': affiliation}); - _converse.connection.sendIQ(iq, resolve, reject); - }); - }, - - parseMemberListIQ (iq) { - /* Given an IQ stanza with a member list, create an array of member - * objects. - */ - return _.map( - sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq), - (item) => ({ - 'jid': item.getAttribute('jid'), - 'affiliation': item.getAttribute('affiliation'), - }) - ); - }, - - computeAffiliationsDelta (exclude_existing, remove_absentees, new_list, old_list) { - /* Given two lists of objects with 'jid', 'affiliation' and - * 'reason' properties, return a new list containing - * those objects that are new, changed or removed - * (depending on the 'remove_absentees' boolean). - * - * The affiliations for new and changed members stay the - * same, for removed members, the affiliation is set to 'none'. - * - * The 'reason' property is not taken into account when - * comparing whether affiliations have been changed. - * - * Parameters: - * (Boolean) exclude_existing: Indicates whether JIDs from - * the new list which are also in the old list - * (regardless of affiliation) should be excluded - * from the delta. One reason to do this - * would be when you want to add a JID only if it - * doesn't have *any* existing affiliation at all. - * (Boolean) remove_absentees: Indicates whether JIDs - * from the old list which are not in the new list - * should be considered removed and therefore be - * included in the delta with affiliation set - * to 'none'. - * (Array) new_list: Array containing the new affiliations - * (Array) old_list: Array containing the old affiliations - */ - const new_jids = _.map(new_list, 'jid'); - const old_jids = _.map(old_list, 'jid'); - - // Get the new affiliations - let delta = _.map( - _.difference(new_jids, old_jids), - (jid) => new_list[_.indexOf(new_jids, jid)] - ); - if (!exclude_existing) { - // Get the changed affiliations - delta = delta.concat(_.filter(new_list, function (item) { - const idx = _.indexOf(old_jids, item.jid); - if (idx >= 0) { - return item.affiliation !== old_list[idx].affiliation; - } - return false; - })); - } - if (remove_absentees) { - // Get the removed affiliations - delta = delta.concat( - _.map( - _.difference(old_jids, new_jids), - (jid) => ({'jid': jid, 'affiliation': 'none'}) - ) - ); - } - return delta; - }, - - sendAffiliationIQ (chatroom_jid, affiliation, member) { - /* Send an IQ stanza specifying an affiliation change. - * - * Paremeters: - * (String) chatroom_jid: JID of the relevant room - * (String) affiliation: affiliation (could also be stored - * on the member object). - * (Object) member: Map containing the member's jid and - * optionally a reason and affiliation. - */ - return new Promise((resolve, reject) => { - const iq = $iq({to: chatroom_jid, type: "set"}) - .c("query", {xmlns: Strophe.NS.MUC_ADMIN}) - .c("item", { - 'affiliation': member.affiliation || affiliation, - 'jid': member.jid - }); - if (!_.isUndefined(member.reason)) { - iq.c("reason", member.reason); - } - _converse.connection.sendIQ(iq, resolve, reject); - }); - }, - - setAffiliation (affiliation, members) { - /* Send IQ stanzas to the server to set an affiliation for - * the provided JIDs. - * - * See: http://xmpp.org/extensions/xep-0045.html#modifymember - * - * XXX: Prosody doesn't accept multiple JIDs' affiliations - * being set in one IQ stanza, so as a workaround we send - * a separate stanza for each JID. - * Related ticket: https://prosody.im/issues/issue/795 - * - * Parameters: - * (String) affiliation: The affiliation - * (Object) members: A map of jids, affiliations and - * optionally reasons. Only those entries with the - * same affiliation as being currently set will be - * considered. - * - * Returns: - * A promise which resolves and fails depending on the - * XMPP server response. - */ - members = _.filter(members, (member) => - // We only want those members who have the right - // affiliation (or none, which implies the provided - // one). - _.isUndefined(member.affiliation) || - member.affiliation === affiliation - ); - const promises = _.map( - members, - _.partial(this.sendAffiliationIQ, this.model.get('jid'), affiliation) - ); - return Promise.all(promises); - }, - - setAffiliations (members) { - /* Send IQ stanzas to the server to modify the - * affiliations in this room. - * - * See: http://xmpp.org/extensions/xep-0045.html#modifymember - * - * Parameters: - * (Object) members: A map of jids, affiliations and optionally reasons - * (Function) onSuccess: callback for a succesful response - * (Function) onError: callback for an error response - */ - const affiliations = _.uniq(_.map(members, 'affiliation')); - _.each(affiliations, _.partial(this.setAffiliation.bind(this), _, members)); - }, - - marshallAffiliationIQs () { - /* Marshall a list of IQ stanzas into a map of JIDs and - * affiliations. - * - * Parameters: - * Any amount of XMLElement objects, representing the IQ - * stanzas. - */ - return _.flatMap(arguments[0], this.parseMemberListIQ); - }, - - getJidsWithAffiliations (affiliations) { - /* Returns a map of JIDs that have the affiliations - * as provided. - */ - if (_.isString(affiliations)) { - affiliations = [affiliations]; - } - return new Promise((resolve, reject) => { - const promises = _.map( - affiliations, - _.partial(this.requestMemberList, this.model.get('jid')) - ); - - Promise.all(promises).then( - _.flow(this.marshallAffiliationIQs.bind(this), resolve), - _.flow(this.marshallAffiliationIQs.bind(this), resolve) - ); - }); - }, - - updateMemberLists (members, affiliations, deltaFunc) { - /* Fetch the lists of users with the given affiliations. - * Then compute the delta between those users and - * the passed in members, and if it exists, send the delta - * to the XMPP server to update the member list. - * - * Parameters: - * (Object) members: Map of member jids and affiliations. - * (String|Array) affiliation: An array of affiliations or - * a string if only one affiliation. - * (Function) deltaFunc: The function to compute the delta - * between old and new member lists. - * - * Returns: - * A promise which is resolved once the list has been - * updated or once it's been established there's no need - * to update the list. - */ - this.getJidsWithAffiliations(affiliations).then((old_members) => { - this.setAffiliations(deltaFunc(members, old_members)); - }); - }, - - directInvite (recipient, reason) { - /* Send a direct invitation as per XEP-0249 - * - * Parameters: - * (String) recipient - JID of the person being invited - * (String) reason - Optional reason for the invitation - */ - if (this.model.get('membersonly')) { - // When inviting to a members-only room, we first add - // the person to the member list by giving them an - // affiliation of 'member' (if they're not affiliated - // already), otherwise they won't be able to join. - const map = {}; map[recipient] = 'member'; - const deltaFunc = _.partial(this.computeAffiliationsDelta, true, false); - this.updateMemberLists( - [{'jid': recipient, 'affiliation': 'member', 'reason': reason}], - ['member', 'owner', 'admin'], - deltaFunc - ); - } - const attrs = { - 'xmlns': 'jabber:x:conference', - 'jid': this.model.get('jid') - }; - if (reason !== null) { attrs.reason = reason; } - if (this.model.get('password')) { attrs.password = this.model.get('password'); } - const invitation = $msg({ - from: _converse.connection.jid, - to: recipient, - id: _converse.connection.getUniqueId() - }).c('x', attrs); - _converse.connection.send(invitation); - _converse.emit('roomInviteSent', { - 'room': this, - 'recipient': recipient, - 'reason': reason - }); - }, - handleChatStateMessage (message) { /* Override the method on the ChatBoxView base class to * ignore notifications in groupchats. @@ -1009,14 +748,14 @@ switch (command) { case 'admin': if (!this.validateRoleChangeCommand(command, args)) { break; } - this.setAffiliation('admin', + this.model.setAffiliation('admin', [{ 'jid': args[0], 'reason': args[1] }]).then(null, this.onCommandError.bind(this)); break; case 'ban': if (!this.validateRoleChangeCommand(command, args)) { break; } - this.setAffiliation('outcast', + this.model.setAffiliation('outcast', [{ 'jid': args[0], 'reason': args[1] }]).then(null, this.onCommandError.bind(this)); @@ -1064,7 +803,7 @@ break; case 'member': if (!this.validateRoleChangeCommand(command, args)) { break; } - this.setAffiliation('member', + this.model.setAffiliation('member', [{ 'jid': args[0], 'reason': args[1] }]).then(null, this.onCommandError.bind(this)); @@ -1078,7 +817,7 @@ break; case 'owner': if (!this.validateRoleChangeCommand(command, args)) { break; } - this.setAffiliation('owner', + this.model.setAffiliation('owner', [{ 'jid': args[0], 'reason': args[1] }]).then(null, this.onCommandError.bind(this)); @@ -1091,7 +830,7 @@ break; case 'revoke': if (!this.validateRoleChangeCommand(command, args)) { break; } - this.setAffiliation('none', + this.model.setAffiliation('none', [{ 'jid': args[0], 'reason': args[1] }]).then(null, this.onCommandError.bind(this)); @@ -1640,27 +1379,6 @@ return; }, - saveAffiliationAndRole (pres) { - /* Parse the presence stanza for the current user's - * affiliation. - * - * Parameters: - * (XMLElement) pres: A stanza. - */ - const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, pres).pop(); - const is_self = pres.querySelector("status[code='110']"); - if (is_self && !_.isNil(item)) { - const affiliation = item.getAttribute('affiliation'); - const role = item.getAttribute('role'); - if (affiliation) { - this.model.save({'affiliation': affiliation}); - } - if (role) { - this.model.save({'role': role}); - } - } - }, - parseXUserElement (x, stanza, is_self) { /* Parse the passed-in * element and construct a map containing relevant @@ -1944,7 +1662,7 @@ * Parameters: * (XMLElement) pres: The stanza */ - this.saveAffiliationAndRole(pres); + this.model.saveAffiliationAndRole(pres); const locked_room = pres.querySelector("status[code='201']"); if (locked_room) { @@ -2409,7 +2127,7 @@ suggestion.text.label, this.model.get('id')) ); if (reason !== null) { - this.chatroomview.directInvite(suggestion.text.value, reason); + this.chatroomview.model.directInvite(suggestion.text.value, reason); } const form = suggestion.target.form, error = form.querySelector('.pure-form-message.error'); diff --git a/src/converse-muc.js b/src/converse-muc.js index c9c5ac91c..0265b1770 100644 --- a/src/converse-muc.js +++ b/src/converse-muc.js @@ -17,7 +17,8 @@ "converse-disco", "backbone.overview", "backbone.orderedlistview", - "backbone.vdomview" + "backbone.vdomview", + "muc-utils" ], factory); }(this, function (u, converse) { "use strict"; @@ -56,6 +57,7 @@ ENTERED: 5 }; + converse.plugins.add('converse-muc', { /* Optional dependencies are other plugins which might be * overridden or relied upon, and therefore need to be loaded before @@ -273,6 +275,46 @@ ); }, + directInvite (recipient, reason) { + /* Send a direct invitation as per XEP-0249 + * + * Parameters: + * (String) recipient - JID of the person being invited + * (String) reason - Optional reason for the invitation + */ + if (this.get('membersonly')) { + // When inviting to a members-only room, we first add + // the person to the member list by giving them an + // affiliation of 'member' (if they're not affiliated + // already), otherwise they won't be able to join. + const map = {}; map[recipient] = 'member'; + const deltaFunc = _.partial(u.computeAffiliationsDelta, true, false); + this.updateMemberLists( + [{'jid': recipient, 'affiliation': 'member', 'reason': reason}], + ['member', 'owner', 'admin'], + deltaFunc + ); + } + const attrs = { + 'xmlns': 'jabber:x:conference', + 'jid': this.get('jid') + }; + if (reason !== null) { attrs.reason = reason; } + if (this.get('password')) { attrs.password = this.get('password'); } + const invitation = $msg({ + from: _converse.connection.jid, + to: recipient, + id: _converse.connection.getUniqueId() + }).c('x', attrs); + _converse.connection.send(invitation); + _converse.emit('roomInviteSent', { + 'room': this, + 'recipient': recipient, + 'reason': reason + }); + }, + + sendConfiguration (config, callback, errback) { /* Send an IQ stanza with the room configuration. * @@ -335,6 +377,162 @@ this.save(features); }, + requestMemberList (affiliation) { + /* Send an IQ stanza to the server, asking it for the + * member-list of this room. + * + * See: http://xmpp.org/extensions/xep-0045.html#modifymember + * + * Parameters: + * (String) affiliation: The specific member list to + * fetch. 'admin', 'owner' or 'member'. + * + * Returns: + * A promise which resolves once the list has been + * retrieved. + */ + return new Promise((resolve, reject) => { + affiliation = affiliation || 'member'; + const iq = $iq({to: this.get('jid'), type: "get"}) + .c("query", {xmlns: Strophe.NS.MUC_ADMIN}) + .c("item", {'affiliation': affiliation}); + _converse.connection.sendIQ(iq, resolve, reject); + }); + }, + + setAffiliation (affiliation, members) { + /* Send IQ stanzas to the server to set an affiliation for + * the provided JIDs. + * + * See: http://xmpp.org/extensions/xep-0045.html#modifymember + * + * XXX: Prosody doesn't accept multiple JIDs' affiliations + * being set in one IQ stanza, so as a workaround we send + * a separate stanza for each JID. + * Related ticket: https://prosody.im/issues/issue/795 + * + * Parameters: + * (String) affiliation: The affiliation + * (Object) members: A map of jids, affiliations and + * optionally reasons. Only those entries with the + * same affiliation as being currently set will be + * considered. + * + * Returns: + * A promise which resolves and fails depending on the + * XMPP server response. + */ + members = _.filter(members, (member) => + // We only want those members who have the right + // affiliation (or none, which implies the provided one). + _.isUndefined(member.affiliation) || + member.affiliation === affiliation + ); + const promises = _.map(members, _.bind(this.sendAffiliationIQ, this, affiliation)); + return Promise.all(promises); + }, + + saveAffiliationAndRole (pres) { + /* Parse the presence stanza for the current user's + * affiliation. + * + * Parameters: + * (XMLElement) pres: A stanza. + */ + const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, pres).pop(); + const is_self = pres.querySelector("status[code='110']"); + if (is_self && !_.isNil(item)) { + const affiliation = item.getAttribute('affiliation'); + const role = item.getAttribute('role'); + if (affiliation) { + this.save({'affiliation': affiliation}); + } + if (role) { + this.save({'role': role}); + } + } + }, + + sendAffiliationIQ (affiliation, member) { + /* Send an IQ stanza specifying an affiliation change. + * + * Paremeters: + * (String) affiliation: affiliation (could also be stored + * on the member object). + * (Object) member: Map containing the member's jid and + * optionally a reason and affiliation. + */ + return new Promise((resolve, reject) => { + const iq = $iq({to: this.get('jid'), type: "set"}) + .c("query", {xmlns: Strophe.NS.MUC_ADMIN}) + .c("item", { + 'affiliation': member.affiliation || affiliation, + 'jid': member.jid + }); + if (!_.isUndefined(member.reason)) { + iq.c("reason", member.reason); + } + _converse.connection.sendIQ(iq, resolve, reject); + }); + }, + + setAffiliations (members) { + /* Send IQ stanzas to the server to modify the + * affiliations in this room. + * + * See: http://xmpp.org/extensions/xep-0045.html#modifymember + * + * Parameters: + * (Object) members: A map of jids, affiliations and optionally reasons + * (Function) onSuccess: callback for a succesful response + * (Function) onError: callback for an error response + */ + const affiliations = _.uniq(_.map(members, 'affiliation')); + _.each(affiliations, _.partial(this.setAffiliation.bind(this), _, members)); + }, + + getJidsWithAffiliations (affiliations) { + /* Returns a map of JIDs that have the affiliations + * as provided. + */ + if (_.isString(affiliations)) { + affiliations = [affiliations]; + } + return new Promise((resolve, reject) => { + const promises = _.map( + affiliations, + _.partial(this.requestMemberList.bind(this)) + ); + Promise.all(promises).then( + _.flow(u.marshallAffiliationIQs, resolve), + _.flow(u.marshallAffiliationIQs, resolve) + ); + }); + }, + + updateMemberLists (members, affiliations, deltaFunc) { + /* Fetch the lists of users with the given affiliations. + * Then compute the delta between those users and + * the passed in members, and if it exists, send the delta + * to the XMPP server to update the member list. + * + * Parameters: + * (Object) members: Map of member jids and affiliations. + * (String|Array) affiliation: An array of affiliations or + * a string if only one affiliation. + * (Function) deltaFunc: The function to compute the delta + * between old and new member lists. + * + * Returns: + * A promise which is resolved once the list has been + * updated or once it's been established there's no need + * to update the list. + */ + this.getJidsWithAffiliations(affiliations).then((old_members) => { + this.setAffiliations(deltaFunc(members, old_members)); + }); + }, + checkForReservedNick (callback, errback) { /* Use service-discovery to ask the XMPP server whether * this user has a reserved nickname for this room. diff --git a/src/utils.js b/src/utils/core.js similarity index 100% rename from src/utils.js rename to src/utils/core.js diff --git a/src/form-utils.js b/src/utils/form.js similarity index 100% rename from src/form-utils.js rename to src/utils/form.js diff --git a/src/utils/muc.js b/src/utils/muc.js new file mode 100644 index 000000000..824af72f6 --- /dev/null +++ b/src/utils/muc.js @@ -0,0 +1,98 @@ +// Converse.js (A browser based XMPP chat client) +// http://conversejs.org +// +// This is the utilities module. +// +// Copyright (c) 2012-2017, Jan-Carel Brand +// Licensed under the Mozilla Public License (MPLv2) +// +/*global define, escape, Jed */ +(function (root, factory) { + define(["converse-core", "utils"], factory); +}(this, function (converse, u) { + "use strict"; + + const { Strophe, sizzle, _ } = converse.env; + + u.computeAffiliationsDelta = function computeAffiliationsDelta (exclude_existing, remove_absentees, new_list, old_list) { + /* Given two lists of objects with 'jid', 'affiliation' and + * 'reason' properties, return a new list containing + * those objects that are new, changed or removed + * (depending on the 'remove_absentees' boolean). + * + * The affiliations for new and changed members stay the + * same, for removed members, the affiliation is set to 'none'. + * + * The 'reason' property is not taken into account when + * comparing whether affiliations have been changed. + * + * Parameters: + * (Boolean) exclude_existing: Indicates whether JIDs from + * the new list which are also in the old list + * (regardless of affiliation) should be excluded + * from the delta. One reason to do this + * would be when you want to add a JID only if it + * doesn't have *any* existing affiliation at all. + * (Boolean) remove_absentees: Indicates whether JIDs + * from the old list which are not in the new list + * should be considered removed and therefore be + * included in the delta with affiliation set + * to 'none'. + * (Array) new_list: Array containing the new affiliations + * (Array) old_list: Array containing the old affiliations + */ + const new_jids = _.map(new_list, 'jid'); + const old_jids = _.map(old_list, 'jid'); + + // Get the new affiliations + let delta = _.map( + _.difference(new_jids, old_jids), + (jid) => new_list[_.indexOf(new_jids, jid)] + ); + if (!exclude_existing) { + // Get the changed affiliations + delta = delta.concat(_.filter(new_list, function (item) { + const idx = _.indexOf(old_jids, item.jid); + if (idx >= 0) { + return item.affiliation !== old_list[idx].affiliation; + } + return false; + })); + } + if (remove_absentees) { + // Get the removed affiliations + delta = delta.concat( + _.map( + _.difference(old_jids, new_jids), + (jid) => ({'jid': jid, 'affiliation': 'none'}) + ) + ); + } + return delta; + }; + + u.parseMemberListIQ = function parseMemberListIQ (iq) { + /* Given an IQ stanza with a member list, create an array of member + * objects. + */ + return _.map( + sizzle(`query[xmlns="${Strophe.NS.MUC_ADMIN}"] item`, iq), + (item) => ({ + 'jid': item.getAttribute('jid'), + 'affiliation': item.getAttribute('affiliation'), + }) + ); + }; + + u.marshallAffiliationIQs = function marshallAffiliationIQs () { + /* Marshall a list of IQ stanzas into a map of JIDs and + * affiliations. + * + * Parameters: + * Any amount of XMLElement objects, representing the IQ + * stanzas. + */ + return _.flatMap(arguments[0], u.parseMemberListIQ); + } + +}));