Просмотр исходного кода

Move various funcitons related to MUC member lists to utils

and out of the MUC views plugin.

Refs #1032
JC Brand 7 лет назад
Родитель
Сommit
ebfd0a8f77
7 измененных файлов с 342 добавлено и 327 удалено
  1. 25 25
      spec/chatroom.js
  2. 11 10
      src/config.js
  3. 9 291
      src/converse-muc-views.js
  4. 199 1
      src/converse-muc.js
  5. 0 0
      src/utils/core.js
  6. 0 0
      src/utils/form.js
  7. 98 0
      src/utils/muc.js

+ 25 - 25
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));
 
                 /* <presence to="dummy@localhost/_converse.js-29092160"
-                *           from="coven@chat.shakespeare.lit/some1">
-                *      <x xmlns="http://jabber.org/protocol/muc#user">
-                *          <item affiliation="owner" jid="dummy@localhost/_converse.js-29092160" role="moderator"/>
-                *          <status code="110"/>
-                *      </x>
-                *  </presence></body>
-                */
+                 *           from="coven@chat.shakespeare.lit/some1">
+                 *      <x xmlns="http://jabber.org/protocol/muc#user">
+                 *          <item affiliation="owner" jid="dummy@localhost/_converse.js-29092160" role="moderator"/>
+                 *          <status code="110"/>
+                 *      </x>
+                 *  </presence></body>
+                 */
                 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(
                             "<message from='dummy@localhost/resource' to='felix.amsel@localhost' id='" +
                                     sent_stanza.nodeTree.getAttribute('id') +
@@ -2140,7 +2140,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')
@@ -2156,7 +2156,7 @@
                         "Error: the \"owner\" 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
@@ -2165,7 +2165,7 @@
                     view.onMessageSubmitted('/owner annoyingGuy@localhost You\'re responsible');
                     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(
                         "<iq to='lounge@localhost' type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
@@ -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(
                         "<iq to='lounge@localhost' type='set' xmlns='jabber:client' id='"+IQ_id+"'>"+
@@ -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();
             }));

+ 11 - 10
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,27 +37,28 @@ 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",
+        "snabbdom":                 "node_modules/snabbdom/dist/snabbdom",
+        "snabbdom-attributes":      "node_modules/snabbdom/dist/snabbdom-attributes",
+        "snabbdom-class":           "node_modules/snabbdom/dist/snabbdom-class",
+        "snabbdom-dataset":         "node_modules/snabbdom/dist/snabbdom-dataset",
+        "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",
+        "utils":                    "src/utils/core",
         "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",
-        "snabbdom-dataset":         "node_modules/snabbdom/dist/snabbdom-dataset",
-        "snabbdom-eventlisteners":  "node_modules/snabbdom/dist/snabbdom-eventlisteners",
-        "snabbdom-props":           "node_modules/snabbdom/dist/snabbdom-props",
-        "snabbdom-style":           "node_modules/snabbdom/dist/snabbdom-style",
-        "tovnode":                  "node_modules/snabbdom/dist/tovnode",
         "xss":                      "node_modules/xss/dist/xss",
         "xss.noconflict":           "src/xss.noconflict",
 

+ 9 - 291
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 <gone/> 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 <presence> 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 <x xmlns='http://jabber.org/protocol/muc#user'>
                      * 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');

+ 199 - 1
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 <presence> 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.

+ 0 - 0
src/utils.js → src/utils/core.js


+ 0 - 0
src/form-utils.js → src/utils/form.js


+ 98 - 0
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 <jc@opkode.com>
+// 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);
+    }
+
+}));