diff --git a/spec/muc.js b/spec/muc.js index bf548716e..59e041a31 100644 --- a/spec/muc.js +++ b/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); diff --git a/src/converse-muc-views.js b/src/converse-muc-views.js index 5ebae91b5..71a06a48e 100644 --- a/src/converse-muc-views.js +++ b/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(); }, diff --git a/src/headless/converse-disco.js b/src/headless/converse-disco.js index c5cb8f7f6..f726dc7c8 100644 --- a/src/headless/converse-disco.js +++ b/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 * diff --git a/src/headless/converse-muc.js b/src/headless/converse-muc.js index e0b663ab2..c7f6e0a4b 100644 --- a/src/headless/converse-muc.js +++ b/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); - } - 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') - } - ); + /** + * 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')); + 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 } - * 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 element, return a copy with a 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(); } } } diff --git a/src/templates/chatroom_details_modal.html b/src/templates/chatroom_details_modal.html index 03ede7408..8d7939de0 100644 --- a/src/templates/chatroom_details_modal.html +++ b/src/templates/chatroom_details_modal.html @@ -9,7 +9,7 @@

{{{o.__('Name')}}}: {{{o.name}}}

{{{o.__('Groupchat address (JID)')}}}: {{{o.jid}}}

-

{{{o.__('Description')}}}: {{{o.description}}}

+

{{{o.__('Description')}}}: {{{o.config.description}}}

{[ if (o.subject) { ]}

{{{o.__('Topic')}}}: {{o.topic}}

{{{o.__('Topic author')}}}: {{{o._.get(o.subject, 'author')}}}

diff --git a/src/templates/chatroom_head.html b/src/templates/chatroom_head.html index e1cf7d1a9..0f71d9c01 100644 --- a/src/templates/chatroom_head.html +++ b/src/templates/chatroom_head.html @@ -14,4 +14,4 @@
-

{{o.description}}

+

{{o.subject}}

diff --git a/webpack.html b/webpack.html index a821728c1..f9b10a0ac 100644 --- a/webpack.html +++ b/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,