Jelajahi Sumber

muc: Store room configuration (e.g. disco#info `fields`) on the MUC

This will make it easier to add config-based functionality, such as
allowing/showing the `/topic` slash command only to those users who are
allowed to set the subject.
JC Brand 5 tahun lalu
induk
melakukan
aa86a8be32

+ 25 - 11
spec/muc.js

@@ -102,10 +102,10 @@
                     ['rosterGroupsFetched', 'chatBoxesFetched'], {},
                     async function (done, _converse) {
 
-                // Mock 'getRoomFeatures', otherwise the room won't be
+                // Mock 'getDiscoInfo', otherwise the room won't be
                 // displayed as it waits first for the features to be returned
                 // (when it's a new room being created).
-                spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(() => Promise.resolve());
+                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
 
                 let jid = 'lounge@montague.lit';
                 let chatroomview, IQ_id;
@@ -3009,13 +3009,13 @@
                 textarea.value = '/help';
                 view.onKeyDown(enter);
                 info_messages = sizzle('.chat-info:not(.chat-event)', view.el);
-                expect(info_messages.length).toBe(19);
+                expect(info_messages.length).toBe(17);
                 let commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
                 expect(commands).toEqual([
                     "You can run the following commands",
                     "/admin", "/ban", "/clear", "/deop", "/destroy",
                     "/help", "/kick", "/me", "/member", "/modtools", "/mute", "/nick",
-                    "/op", "/register", "/revoke", "/subject", "/topic", "/voice"
+                    "/op", "/register", "/revoke", "/voice"
                 ]);
                 occupant.set('affiliation', 'member');
                 textarea.value = '/clear';
@@ -3025,9 +3025,9 @@
                 textarea.value = '/help';
                 view.onKeyDown(enter);
                 info_messages = sizzle('.chat-info', view.el).slice(1);
-                expect(info_messages.length).toBe(11);
+                expect(info_messages.length).toBe(9);
                 commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
-                expect(commands).toEqual(["/clear", "/help", "/kick", "/me", "/modtools", "/mute", "/nick", "/register", "/subject", "/topic", "/voice"]);
+                expect(commands).toEqual(["/clear", "/help", "/kick", "/me", "/modtools", "/mute", "/nick", "/register", "/voice"]);
 
                 occupant.set('role', 'participant');
                 textarea = view.el.querySelector('.chat-textarea');
@@ -3035,6 +3035,20 @@
                 view.onKeyDown(enter);
                 await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
 
+                textarea.value = '/help';
+                view.onKeyDown(enter);
+                info_messages = sizzle('.chat-info', view.el).slice(1);
+                expect(info_messages.length).toBe(5);
+                commands = info_messages.map(m => m.textContent.replace(/:.*$/, ''));
+                expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register"]);
+
+                // Test that /topic is available if all users may change the subject
+                // Note: we're making a shortcut here, this value should never be set manually
+                view.model.config.set('changesubject', true);
+                textarea.value = '/clear';
+                view.onKeyDown(enter);
+                await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view.el).length === 0);
+
                 textarea.value = '/help';
                 view.onKeyDown(enter);
                 info_messages = sizzle('.chat-info', view.el).slice(1);
@@ -4572,7 +4586,7 @@
                 nick_input.value = 'romeo';
 
                 expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
-                spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(() => Promise.resolve());
+                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
                 roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
                 modal.el.querySelector('input[name="chatroom"]').value = 'lounce@muc.montague.lit';
                 modal.el.querySelector('form input[type="submit"]').click();
@@ -4660,7 +4674,7 @@
                 const modal = roomspanel.add_room_modal;
                 await u.waitUntil(() => u.isVisible(modal.el), 1000)
                 expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
-                spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(() => Promise.resolve());
+                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
                 roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
                 const label_name = modal.el.querySelector('label[for="chatroom"]');
                 expect(label_name.textContent.trim()).toBe('Groupchat name:');
@@ -4700,7 +4714,7 @@
                 const modal = roomspanel.add_room_modal;
                 await u.waitUntil(() => u.isVisible(modal.el), 1000)
                 expect(modal.el.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat');
-                spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(() => Promise.resolve());
+                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
                 roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
                 const label_name = modal.el.querySelector('label[for="chatroom"]');
                 expect(label_name.textContent.trim()).toBe('Groupchat name:');
@@ -4742,7 +4756,7 @@
                 test_utils.closeControlBox(_converse);
                 const modal = roomspanel.list_rooms_modal;
                 await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(() => Promise.resolve());
+                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
                 roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
 
                 // See: https://xmpp.org/extensions/xep-0045.html#disco-rooms
@@ -4836,7 +4850,7 @@
                 test_utils.closeControlBox(_converse);
                 const modal = roomspanel.list_rooms_modal;
                 await u.waitUntil(() => u.isVisible(modal.el), 1000);
-                spyOn(_converse.ChatRoom.prototype, 'getRoomFeatures').and.callFake(() => Promise.resolve());
+                spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve());
                 roomspanel.delegateEvents(); // We need to rebind all events otherwise our spy won't be called
 
                 expect(modal.el.querySelector('input[name="server"]')).toBe(null);

+ 19 - 4
src/converse-muc-views.js

@@ -625,6 +625,7 @@ converse.plugins.add('converse-muc-views', {
                     this.model.toJSON(), {
                         '_': _,
                         '__': __,
+                        'config': this.model.config.toJSON(),
                         'display_name': __('Groupchat info for %1$s', this.model.getDisplayName()),
                         'features': this.model.features.toJSON(),
                         'num_occupants': this.model.occupants.length,
@@ -1126,7 +1127,7 @@ converse.plugins.add('converse-muc-views', {
                         'info_close': __('Close and leave this groupchat'),
                         'info_configure': __('Configure this groupchat'),
                         'info_details': __('Show more details about this groupchat'),
-                        'description': u.addHyperlinks(xss.filterXSS(_.get(this.model.get('subject'), 'text'), {'whiteList': {}})),
+                        'subject': u.addHyperlinks(xss.filterXSS(_.get(this.model.get('subject'), 'text'), {'whiteList': {}})),
                 }));
             },
 
@@ -1365,7 +1366,10 @@ converse.plugins.add('converse-muc-views', {
 
             onCommandError (err) {
                 log.fatal(err);
-                this.showErrorMessage(__("Sorry, an error happened while running the command. Check your browser's developer console for details."));
+                this.showErrorMessage(
+                    __("Sorry, an error happened while running the command.") + " " +
+                    __("Check your browser's developer console for details.")
+                );
             },
 
             getAllowedCommands () {
@@ -2063,9 +2067,20 @@ converse.plugins.add('converse-muc-views', {
                 });
             },
 
-            submitConfigForm (ev) {
+            async submitConfigForm (ev) {
                 ev.preventDefault();
-                this.model.saveConfiguration(ev.target).then(() => this.model.refreshRoomFeatures());
+                const inputs = sizzle(':input:not([type=button]):not([type=submit])', ev.target);
+                const configArray = inputs.map(u.webForm2xForm);
+                try {
+                    await this.model.sendConfiguration(configArray);
+                } catch (e) {
+                    log.error(e);
+                    this.showErrorMessage(
+                        __("Sorry, an error occurred while trying to submit the config form.") + " " +
+                        __("Check your browser's developer console for details.")
+                    );
+                }
+                await this.model.refreshDiscoInfo();
                 this.chatroomview.closeForm();
             },
 

+ 13 - 6
src/headless/converse-disco.js

@@ -692,18 +692,17 @@ converse.plugins.add('converse-disco', {
                 },
 
                 /**
-                 * Refresh the features (and fields and identities) associated with a
+                 * Refresh the features, fields and identities associated with a
                  * disco entity by refetching them from the server
-                 *
-                 * @method _converse.api.disco.refreshFeatures
+                 * @method _converse.api.disco.refresh
                  * @param {string} jid The JID of the entity whose features are refreshed.
                  * @returns {promise} A promise which resolves once the features have been refreshed
                  * @example
-                 * await _converse.api.disco.refreshFeatures('room@conference.example.org');
+                 * await _converse.api.disco.refresh('room@conference.example.org');
                  */
-                async refreshFeatures (jid) {
+                async refresh (jid) {
                     if (!jid) {
-                        throw new TypeError('api.disco.refreshFeatures: You need to provide an entity JID');
+                        throw new TypeError('api.disco.refresh: You need to provide an entity JID');
                     }
                     await _converse.api.waitUntil('discoInitialized');
                     let entity = await _converse.api.disco.entities.get(jid);
@@ -722,6 +721,14 @@ converse.plugins.add('converse-disco', {
                     return entity.waitUntilFeaturesDiscovered;
                 },
 
+                /**
+                 * @deprecated Use {@link _converse.api.disco.refresh} instead.
+                 * @method _converse.api.disco.refreshFeatures
+                 */
+                refreshFeatures (jid) {
+                    return _converse.api.refresh(jid);
+                },
+
                 /**
                  * Return all the features associated with a disco entity
                  *

+ 69 - 48
src/headless/converse-muc.js

@@ -337,7 +337,6 @@ converse.plugins.add('converse-muc', {
 
                     'bookmarked': false,
                     'chat_state': undefined,
-                    'description': '',
                     'hidden': ['mobile', 'fullscreen'].includes(_converse.view_mode),
                     'message_type': 'groupchat',
                     'name': '',
@@ -354,7 +353,7 @@ converse.plugins.add('converse-muc', {
                 this.set('box_id', `box-${btoa(this.get('jid'))}`);
                 this.initMessages();
                 this.initOccupants();
-                this.initFeatures(); // sendChatState depends on this.features
+                this.initDiscoModels(); // sendChatState depends on this.features
                 this.registerHandlers();
 
                 this.on('change:chat_state', this.sendChatState, this);
@@ -407,7 +406,7 @@ converse.plugins.add('converse-muc', {
                     // so we don't send out a presence stanza again.
                     return this;
                 }
-                await this.refreshRoomFeatures();
+                await this.refreshDiscoInfo();
                 nick = await this.getAndPersistNickname(nick);
                 if (!nick) {
                     u.safeSave(this.session, {'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED});
@@ -488,12 +487,16 @@ converse.plugins.add('converse-muc', {
                 return new Promise(r => this.session.fetch({'success': r, 'error': r}));
             },
 
-            initFeatures () {
-                const id = `converse.muc-features-${_converse.bare_jid}-${this.get('jid')}`;
+            initDiscoModels () {
+                let id = `converse.muc-features-${_converse.bare_jid}-${this.get('jid')}`;
                 this.features = new Backbone.Model(
                     Object.assign({id}, zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(() => false)))
                 );
                 this.features.browserStorage = _converse.createStore(id, "session");
+
+                id = `converse.muc-config-{_converse.bare_jid}-${this.get('jid')}`;
+                this.config = new Backbone.Model();
+                this.config.browserStorage = _converse.createStore(id, "session");
             },
 
             initOccupants () {
@@ -917,10 +920,10 @@ converse.plugins.add('converse-muc', {
                  * After the user has sent out a direct invitation (as per XEP-0249),
                  * to a roster contact, asking them to join a room.
                  * @event _converse#chatBoxMaximized
-                 * @type { object }
-                 * @property { _converse.ChatRoom } room
-                 * @property { string } recipient - The JID of the person being invited
-                 * @property { string } reason - The original reason for the invitation
+                 * @type {object}
+                 * @property {_converse.ChatRoom} room
+                 * @property {string} recipient - The JID of the person being invited
+                 * @property {string} reason - The original reason for the invitation
                  * @example _converse.api.listen.on('chatBoxMaximized', view => { ... });
                  */
                 _converse.api.trigger('roomInviteSent', {
@@ -930,26 +933,63 @@ converse.plugins.add('converse-muc', {
                 });
             },
 
-            async refreshRoomFeatures () {
-                await _converse.api.disco.refreshFeatures(this.get('jid'));
-                return this.getRoomFeatures();
+            /**
+             * Refresh the disco identity, features and fields for this {@link _converse.ChatRoom}.
+             * *features* are stored on the features {@link Model} attribute on this {@link _converse.ChatRoom}.
+             * *fields* are stored on the config {@link Model} attribute on this {@link _converse.ChatRoom}.
+             * @private
+             * @returns {Promise}
+             */
+            refreshDiscoInfo () {
+                return _converse.api.disco.refresh(this.get('jid'))
+                    .then(() => this.getDiscoInfo())
+                    .catch(e => log.error(e));
             },
 
-            async getRoomFeatures () {
-                let identity;
-                try {
-                    identity = await _converse.api.disco.getIdentity('conference', 'text', this.get('jid'));
-                } catch (e) {
-                    // Getting the identity probably failed because this room doesn't exist yet.
-                    return log.error(e);
-                }
+            /**
+             * Fetch the *extended* MUC info from the server and cache it locally
+             * https://xmpp.org/extensions/xep-0045.html#disco-roominfo
+             * @private
+             * @method _converse.ChatRoom#getDiscoInfo
+             * @returns {Promise}
+             */
+            getDiscoInfo () {
+                return _converse.api.disco.getIdentity('conference', 'text', this.get('jid'))
+                    .then(identity => this.save({'name': identity && identity.get('name')}))
+                    .then(() => this.getDiscoInfoFields())
+                    .then(() => this.getDiscoInfoFeatures())
+                    .catch(e => log.error(e));
+            },
+
+            /**
+             * Fetch the *extended* MUC info fields from the server and store them locally
+             * in the `config` {@link Model} attribute.
+             * See: https://xmpp.org/extensions/xep-0045.html#disco-roominfo
+             * @private
+             * @method _converse.ChatRoom#getDiscoInfoFields
+             * @returns {Promise}
+             */
+            async getDiscoInfoFields () {
                 const fields = await _converse.api.disco.getFields(this.get('jid'));
-                this.save({
-                        'name': identity && identity.get('name'),
-                        'description': get(fields.findWhere({'var': "muc#roominfo_description"}), 'attributes.value')
+                const config = fields.reduce((config, f) => {
+                    const name = f.get('var');
+                    if (name && name.startsWith('muc#roominfo_')) {
+                        config[name.replace('muc#roominfo_', '')] = f.get('value');
                     }
-                );
+                    return config;
+                }, {});
+                this.config.save(config);
+            },
 
+            /**
+             * Use converse-disco to populate the features {@link Model} which
+             * is stored as an attibute on this {@link _converse.ChatRoom}.
+             * The results may be cached. If you want to force fetching the features from the
+             * server, call {@link _converse.ChatRoom#refreshDiscoInfo} instead.
+             * @private
+             * @returns {Promise}
+             */
+            async getDiscoInfoFeatures () {
                 const features = await _converse.api.disco.getFeatures(this.get('jid'));
                 const attrs = Object.assign(
                     zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(() => false)),
@@ -965,7 +1005,6 @@ converse.plugins.add('converse-muc', {
                     }
                     attrs[fieldname.replace('muc_', '')] = true;
                 });
-                attrs.description = get(fields.findWhere({'var': "muc#roominfo_description"}), 'attributes.value');
                 this.features.save(attrs);
             },
 
@@ -992,24 +1031,6 @@ converse.plugins.add('converse-muc', {
                 return Promise.all(members.map(m => this.sendAffiliationIQ(affiliation, m)));
             },
 
-            /**
-             * Submit the groupchat configuration form by sending an IQ
-             * stanza to the server.
-             * @private
-             * @method _converse.ChatRoom#saveConfiguration
-             * @param { HTMLElement } form - The configuration form DOM element.
-             *      If no form is provided, the default configuration
-             *      values will be used.
-             * @returns { Promise<XMLElement> }
-             * Returns a promise which resolves once the XMPP server
-             * has return a response IQ.
-             */
-            saveConfiguration (form) {
-                const inputs = form ? sizzle(':input:not([type=button]):not([type=submit])', form) : [];
-                const configArray = inputs.map(u.webForm2xForm);
-                return this.sendConfiguration(configArray);
-            },
-
             /**
              * Given a <field> element, return a copy with a <value> child if
              * we can find a value for it in this rooms config.
@@ -1433,7 +1454,7 @@ converse.plugins.add('converse-muc', {
                 // 174: room now fully anonymous
                 const codes = ['104', '170', '171', '172', '173', '174'];
                 if (sizzle('status', stanza).filter(e => codes.includes(e.getAttribute('status'))).length) {
-                    this.refreshRoomFeatures();
+                    this.refreshDiscoInfo();
                 }
             },
 
@@ -1983,10 +2004,10 @@ converse.plugins.add('converse-muc', {
                     const locked_room = stanza.querySelector("status[code='201']");
                     if (locked_room) {
                         if (this.get('auto_configure')) {
-                            this.autoConfigureChatRoom().then(() => this.refreshRoomFeatures());
+                            this.autoConfigureChatRoom().then(() => this.refreshDiscoInfo());
                         } else if (_converse.muc_instant_rooms) {
                             // Accept default configuration
-                            this.saveConfiguration().then(() => this.refreshRoomFeatures());
+                            this.sendConfiguration().then(() => this.refreshDiscoInfo());
                         } else {
                             /**
                              * Triggered when a new room has been created which first needs to be configured
@@ -2006,9 +2027,9 @@ converse.plugins.add('converse-muc', {
                         // otherwise the features would have been fetched in
                         // the "initialize" method already.
                         if (this.getOwnAffiliation() === 'owner' && this.get('auto_configure')) {
-                            this.autoConfigureChatRoom().then(() => this.refreshRoomFeatures());
+                            this.autoConfigureChatRoom().then(() => this.refreshDiscoInfo());
                         } else {
-                            this.getRoomFeatures();
+                            this.getDiscoInfo();
                         }
                     }
                 }

+ 1 - 1
src/templates/chatroom_details_modal.html

@@ -9,7 +9,7 @@
                 <div class="room-info">
                     <p class="room-info"><strong>{{{o.__('Name')}}}</strong>: {{{o.name}}}</p>
                     <p class="room-info"><strong>{{{o.__('Groupchat address (JID)')}}}</strong>: {{{o.jid}}}</p>
-                    <p class="room-info"><strong>{{{o.__('Description')}}}</strong>: {{{o.description}}}</p>
+                    <p class="room-info"><strong>{{{o.__('Description')}}}</strong>: {{{o.config.description}}}</p>
                     {[ if (o.subject) { ]}
                     <p class="room-info"><strong>{{{o.__('Topic')}}}</strong>: {{o.topic}}</p> <!-- Sanitized in converse-muc-views. We want to render links. -->
                         <p class="room-info"><strong>{{{o.__('Topic author')}}}</strong>: {{{o._.get(o.subject, 'author')}}}</p>

+ 1 - 1
src/templates/chatroom_head.html

@@ -14,4 +14,4 @@
     </div>
 </div>
 <!-- Sanitized in converse-muc-views. We want to render links. -->
-<p class="chat-head__desc">{{o.description}}</p>
+<p class="chat-head__desc">{{o.subject}}</p>

+ 1 - 1
webpack.html

@@ -28,7 +28,7 @@
         persistent_store: 'IndexedDB',
         muc_domain: 'conference.chat.example.org',
         muc_respect_autojoin: true,
-        view_mode: 'overlayed',
+        view_mode: 'fullscreen',
         websocket_url: 'ws://chat.example.org:5380/xmpp-websocket',
         // bosh_service_url: 'http://chat.example.org:5280/http-bind',
         muc_show_logs_before_join: true,