UNPKG

converse.js

Version:
1,135 lines (1,047 loc) 108 kB
// Converse.js // https://conversejs.org // // Copyright (c) 2013-2019, the Converse.js developers // Licensed under the Mozilla Public License (MPLv2) // /** * @module converse-muc * @description * Implements the non-view logic for XEP-0045 Multi-User Chat */ import "./converse-disco"; import "./converse-emoji"; import "./utils/muc"; import BrowserStorage from "backbone.browserStorage"; import converse from "./converse-core"; import u from "./utils/form"; const MUC_ROLE_WEIGHTS = { 'moderator': 1, 'participant': 2, 'visitor': 3, 'none': 2, }; const { Strophe, Backbone, $iq, $build, $msg, $pres, sizzle, _ } = converse.env; // Add Strophe Namespaces Strophe.addNamespace('MUC_ADMIN', Strophe.NS.MUC + "#admin"); Strophe.addNamespace('MUC_OWNER', Strophe.NS.MUC + "#owner"); Strophe.addNamespace('MUC_REGISTER', "jabber:iq:register"); Strophe.addNamespace('MUC_ROOMCONF', Strophe.NS.MUC + "#roomconfig"); Strophe.addNamespace('MUC_USER', Strophe.NS.MUC + "#user"); converse.MUC_NICK_CHANGED_CODE = "303"; converse.ROOM_FEATURES = [ 'passwordprotected', 'unsecured', 'hidden', 'publicroom', 'membersonly', 'open', 'persistent', 'temporary', 'nonanonymous', 'semianonymous', 'moderated', 'unmoderated', 'mam_enabled' ]; // No longer used in code, but useful as reference. // // const ROOM_FEATURES_MAP = { // 'passwordprotected': 'unsecured', // 'unsecured': 'passwordprotected', // 'hidden': 'publicroom', // 'publicroom': 'hidden', // 'membersonly': 'open', // 'open': 'membersonly', // 'persistent': 'temporary', // 'temporary': 'persistent', // 'nonanonymous': 'semianonymous', // 'semianonymous': 'nonanonymous', // 'moderated': 'unmoderated', // 'unmoderated': 'moderated' // }; converse.ROOMSTATUS = { CONNECTED: 0, CONNECTING: 1, NICKNAME_REQUIRED: 2, PASSWORD_REQUIRED: 3, DISCONNECTED: 4, ENTERED: 5, DESTROYED: 6 }; converse.plugins.add('converse-muc', { /* Optional dependencies are other plugins which might be * overridden or relied upon, and therefore need to be loaded before * this plugin. They are called "optional" because they might not be * available, in which case any overrides applicable to them will be * ignored. * * It's possible however to make optional dependencies non-optional. * If the setting "strict_plugin_dependencies" is set to true, * an error will be raised if the plugin is not found. * * NB: These plugins need to have already been loaded via require.js. */ dependencies: ["converse-chatboxes", "converse-disco", "converse-controlbox"], overrides: { ChatBoxes: { model (attrs, options) { const { _converse } = this.__super__; if (attrs.type == _converse.CHATROOMS_TYPE) { return new _converse.ChatRoom(attrs, options); } else { return this.__super__.model.apply(this, arguments); } }, } }, initialize () { /* The initialize function gets called as soon as the plugin is * loaded by converse.js's plugin machinery. */ const { _converse } = this, { __ } = _converse; // Configuration values for this plugin // ==================================== // Refer to docs/source/configuration.rst for explanations of these // configuration settings. _converse.api.settings.update({ 'allow_muc': true, 'allow_muc_invitations': true, 'auto_join_on_invite': false, 'auto_join_rooms': [], 'auto_register_muc_nickname': false, 'locked_muc_domain': false, 'muc_domain': undefined, 'muc_fetch_members': true, 'muc_history_max_stanzas': undefined, 'muc_instant_rooms': true, 'muc_nickname_from_jid': false }); _converse.api.promises.add(['roomsAutoJoined']); if (_converse.locked_muc_domain && !_.isString(_converse.muc_domain)) { throw new Error("Config Error: it makes no sense to set locked_muc_domain "+ "to true when muc_domain is not set"); } function ___ (str) { /* This is part of a hack to get gettext to scan strings to be * translated. Strings we cannot send to the function above because * they require variable interpolation and we don't yet have the * variables at scan time. */ return str; } /* https://xmpp.org/extensions/xep-0045.html * ---------------------------------------- * 100 message Entering a groupchat Inform user that any occupant is allowed to see the user's full JID * 101 message (out of band) Affiliation change Inform user that his or her affiliation changed while not in the groupchat * 102 message Configuration change Inform occupants that groupchat now shows unavailable members * 103 message Configuration change Inform occupants that groupchat now does not show unavailable members * 104 message Configuration change Inform occupants that a non-privacy-related groupchat configuration change has occurred * 110 presence Any groupchat presence Inform user that presence refers to one of its own groupchat occupants * 170 message or initial presence Configuration change Inform occupants that groupchat logging is now enabled * 171 message Configuration change Inform occupants that groupchat logging is now disabled * 172 message Configuration change Inform occupants that the groupchat is now non-anonymous * 173 message Configuration change Inform occupants that the groupchat is now semi-anonymous * 174 message Configuration change Inform occupants that the groupchat is now fully-anonymous * 201 presence Entering a groupchat Inform user that a new groupchat has been created * 210 presence Entering a groupchat Inform user that the service has assigned or modified the occupant's roomnick * 301 presence Removal from groupchat Inform user that he or she has been banned from the groupchat * 303 presence Exiting a groupchat Inform all occupants of new groupchat nickname * 307 presence Removal from groupchat Inform user that he or she has been kicked from the groupchat * 321 presence Removal from groupchat Inform user that he or she is being removed from the groupchat because of an affiliation change * 322 presence Removal from groupchat Inform user that he or she is being removed from the groupchat because the groupchat has been changed to members-only and the user is not a member * 332 presence Removal from groupchat Inform user that he or she is being removed from the groupchat because of a system shutdown */ _converse.muc = { info_messages: { 100: __('This groupchat is not anonymous'), 102: __('This groupchat now shows unavailable members'), 103: __('This groupchat does not show unavailable members'), 104: __('The groupchat configuration has changed'), 170: __('Groupchat logging is now enabled'), 171: __('Groupchat logging is now disabled'), 172: __('This groupchat is now no longer anonymous'), 173: __('This groupchat is now semi-anonymous'), 174: __('This groupchat is now fully-anonymous'), 201: __('A new groupchat has been created') }, new_nickname_messages: { // XXX: Note the triple underscore function and not double underscore. 210: ___('Your nickname has been automatically set to %1$s'), 303: ___('Your nickname has been changed to %1$s') }, disconnect_messages: { 301: __('You have been banned from this groupchat'), 307: __('You have been kicked from this groupchat'), 321: __("You have been removed from this groupchat because of an affiliation change"), 322: __("You have been removed from this groupchat because the groupchat has changed to members-only and you're not a member"), 332: __("You have been removed from this groupchat because the service hosting it is being shut down") }, action_info_messages: { // XXX: Note the triple underscore function and not double underscore. 301: ___("%1$s has been banned"), 303: ___("%1$s's nickname has changed"), 307: ___("%1$s has been kicked out"), 321: ___("%1$s has been removed because of an affiliation change"), 322: ___("%1$s has been removed for not being a member") } } async function openRoom (jid) { if (!u.isValidMUCJID(jid)) { return _converse.log( `Invalid JID "${jid}" provided in URL fragment`, Strophe.LogLevel.WARN ); } await _converse.api.waitUntil('roomsAutoJoined'); if (_converse.allow_bookmarks) { await _converse.api.waitUntil('bookmarksInitialized'); } _converse.api.rooms.open(jid); } _converse.router.route('converse/room?jid=:jid', openRoom); _converse.getDefaultMUCNickname = function () { // XXX: if anything changes here, update the docs for the // locked_muc_nickname setting. if (!_converse.xmppstatus) { throw new Error( "Can't call _converse.getDefaultMUCNickname before the statusInitialized has been fired."); } const nick = _converse.xmppstatus.getNickname(); if (nick) { return nick; } else if (_converse.muc_nickname_from_jid) { return Strophe.unescapeNode(Strophe.getNodeFromJid(_converse.bare_jid)); } } function openChatRoom (jid, settings) { /* Opens a groupchat, making sure that certain attributes * are correct, for example that the "type" is set to * "chatroom". */ settings.type = _converse.CHATROOMS_TYPE; settings.id = jid; const chatbox = _converse.chatboxes.getChatBox(jid, settings, true); chatbox.maybeShow(true); return chatbox; } /** * Represents a MUC message * @class * @namespace _converse.ChatRoomMessage * @memberOf _converse */ _converse.ChatRoomMessage = _converse.Message.extend({ initialize () { if (this.get('file')) { this.on('change:put', this.uploadFile, this); } if (this.isEphemeral()) { window.setTimeout(this.safeDestroy.bind(this), 10000); } else { this.setOccupant(); this.setVCard(); } }, onOccupantRemoved () { this.stopListening(this.occupant); delete this.occupant; const chatbox = _.get(this, 'collection.chatbox'); if (!chatbox) { return _converse.log( `Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`, Strophe.LogLevel.ERROR ); } this.listenTo(chatbox.occupants, 'add', this.onOccupantAdded); }, onOccupantAdded (occupant) { if (occupant.get('nick') === Strophe.getResourceFromJid(this.get('from'))) { this.occupant = occupant; this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved); const chatbox = _.get(this, 'collection.chatbox'); if (!chatbox) { return _converse.log( `Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`, Strophe.LogLevel.ERROR ); } this.stopListening(chatbox.occupants, 'add', this.onOccupantAdded); } }, setOccupant () { if (this.get('type') !== 'groupchat') { return; } const chatbox = _.get(this, 'collection.chatbox'); if (!chatbox) { return _converse.log( `Could not get collection.chatbox for message: ${JSON.stringify(this.toJSON())}`, Strophe.LogLevel.ERROR ); } const nick = Strophe.getResourceFromJid(this.get('from')); this.occupant = chatbox.occupants.findWhere({'nick': nick}); if (this.occupant) { this.listenTo(this.occupant, 'destroy', this.onOccupantRemoved); } else { this.listenTo(chatbox.occupants, 'add', this.onOccupantAdded); } }, getVCardForChatroomOccupant () { const chatbox = _.get(this, 'collection.chatbox'); const nick = Strophe.getResourceFromJid(this.get('from')); if (chatbox && chatbox.get('nick') === nick) { return _converse.xmppstatus.vcard; } else { let vcard; if (this.get('vcard_jid')) { vcard = _converse.vcards.findWhere({'jid': this.get('vcard_jid')}); } if (!vcard) { let jid; if (this.occupant && this.occupant.get('jid')) { jid = this.occupant.get('jid'); this.save({'vcard_jid': jid}, {'silent': true}); } else { jid = this.get('from'); } if (jid) { vcard = _converse.vcards.findWhere({'jid': jid}) || _converse.vcards.create({'jid': jid}); } else { _converse.log( `Could not assign VCard for message because no JID found! msgid: ${this.get('msgid')}`, Strophe.LogLevel.ERROR ); return; } } return vcard; } }, setVCard () { if (!_converse.vcards) { // VCards aren't supported return; } if (['error', 'info'].includes(this.get('type'))) { return; } else { this.vcard = this.getVCardForChatroomOccupant(); } }, }); /** * Collection which stores MUC messages * @class * @namespace _converse.ChatRoomMessages * @memberOf _converse */ _converse.ChatRoomMessages = _converse.Collection.extend({ model: _converse.ChatRoomMessage, comparator: 'time' }); /** * Represents an open/ongoing groupchat conversation. * @class * @namespace _converse.ChatRoom * @memberOf _converse */ _converse.ChatRoom = _converse.ChatBox.extend({ messagesCollection: _converse.ChatRoomMessages, defaults () { return { // For group chats, we distinguish between generally unread // messages and those ones that specifically mention the // user. // // To keep things simple, we reuse `num_unread` from // _converse.ChatBox to indicate unread messages which // mention the user and `num_unread_general` to indicate // generally unread messages (which *includes* mentions!). 'num_unread_general': 0, 'bookmarked': false, 'chat_state': undefined, 'connection_status': converse.ROOMSTATUS.DISCONNECTED, 'description': '', 'hidden': ['mobile', 'fullscreen'].includes(_converse.view_mode), 'message_type': 'groupchat', 'name': '', 'num_unread': 0, 'roomconfig': {}, 'time_sent': (new Date(0)).toISOString(), 'time_opened': this.get('time_opened') || (new Date()).getTime(), 'type': _converse.CHATROOMS_TYPE } }, async initialize() { if (_converse.vcards) { this.vcard = _converse.vcards.findWhere({'jid': this.get('jid')}) || _converse.vcards.create({'jid': this.get('jid')}); } this.set('box_id', `box-${btoa(this.get('jid'))}`); this.initFeatures(); // sendChatState depends on this.features this.on('change:chat_state', this.sendChatState, this); this.on('change:connection_status', this.onConnectionStatusChanged, this); this.initMessages(); this.registerHandlers(); await this.initOccupants(); await this.fetchMessages(); this.enterRoom(); }, async enterRoom () { const conn_status = this.get('connection_status'); _converse.log( `${this.get('jid')} initialized with connection_status ${conn_status}`, Strophe.LogLevel.DEBUG ); if (conn_status !== converse.ROOMSTATUS.ENTERED) { // We're not restoring a room from cache, so let's clear the potentially stale cache. this.removeNonMembers(); await this.refreshRoomFeatures(); if (_converse.clear_messages_on_reconnection) { this.clearMessages(); } if (!u.isPersistableModel(this)) { // XXX: Happens during tests, nothing to do if this // is a hanging chatbox (i.e. not in the collection anymore). return; } this.join(); } else if (!(await this.rejoinIfNecessary())) { // We've restored the room from cache and we're still joined. this.features.fetch(); } }, async onConnectionStatusChanged () { if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED) { if (_converse.muc_fetch_members) { await this.occupants.fetchMembers(); } /** * Triggered when the user has entered a new MUC * @event _converse#enteredNewRoom * @type { _converse.ChatRoom} * @example _converse.api.listen.on('enteredNewRoom', model => { ... }); */ _converse.api.trigger('enteredNewRoom', this); if (_converse.auto_register_muc_nickname && await _converse.api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid'))) { this.registerNickname() } } }, removeNonMembers () { const non_members = this.occupants.filter(o => !o.isMember()); if (non_members.length) { non_members.forEach(o => o.destroy()); } }, async onReconnection () { this.save('connection_status', converse.ROOMSTATUS.DISCONNECTED); this.registerHandlers(); await this.enterRoom(); this.announceReconnection(); }, initFeatures () { const id = `converse.muc-features-${_converse.bare_jid}-${this.get('jid')}`; this.features = new Backbone.Model( _.assign({id}, _.zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(_.stubFalse))) ); this.features.browserStorage = new BrowserStorage.session(id); }, initOccupants () { this.occupants = new _converse.ChatRoomOccupants(); this.occupants.browserStorage = new BrowserStorage.session( `converse.occupants-${_converse.bare_jid}${this.get('jid')}` ); this.occupants.chatroom = this; this.occupants.fetched = new Promise(resolve => { this.occupants.fetch({ 'add': true, 'silent': true, 'success': resolve, 'error': resolve }); }); return this.occupants.fetched; }, registerHandlers () { // Register presence and message handlers for this groupchat const room_jid = this.get('jid'); this.removeHandlers(); this.presence_handler = _converse.connection.addHandler(stanza => { this.onPresence(stanza); return true; }, null, 'presence', null, null, room_jid, {'ignoreNamespaceFragment': true, 'matchBareFromJid': true} ); this.message_handler = _converse.connection.addHandler(stanza => { if (sizzle(`message > result[xmlns="${Strophe.NS.MAM}"]`, stanza).pop()) { // MAM messages are handled in converse-mam. // We shouldn't get MAM messages here because // they shouldn't have a `type` attribute. _converse.log(`Received a MAM message with type "chat".`, Strophe.LogLevel.WARN); return true; } this.onMessage(stanza); return true; }, null, 'message', 'groupchat', null, room_jid, {'matchBareFromJid': true} ); }, removeHandlers () { /* Remove the presence and message handlers that were * registered for this groupchat. */ if (this.message_handler) { _converse.connection.deleteHandler(this.message_handler); delete this.message_handler; } if (this.presence_handler) { _converse.connection.deleteHandler(this.presence_handler); delete this.presence_handler; } return this; }, getDisplayName () { const name = this.get('name'); if (name) { return name; } else if (_converse.locked_muc_domain === 'hidden') { return Strophe.getNodeFromJid(this.get('jid')); } else { return this.get('jid'); } }, /** * Join the groupchat. * @private * @method _converse.ChatRoom#join * @param { String } nick - The user's nickname * @param { String } [password] - Optional password, if required by the groupchat. */ async join (nick, password) { if (this.get('connection_status') === converse.ROOMSTATUS.ENTERED) { // We have restored a groupchat from session storage, // so we don't send out a presence stanza again. return this; } nick = await this.getAndPersistNickname(nick); if (!nick) { u.safeSave(this, {'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED}); return this; } const stanza = $pres({ 'from': _converse.connection.jid, 'to': this.getRoomJIDAndNick() }).c("x", {'xmlns': Strophe.NS.MUC}) .c("history", {'maxstanzas': this.features.get('mam_enabled') ? 0 : _converse.muc_history_max_stanzas}).up(); if (password) { stanza.cnode(Strophe.xmlElement("password", [], password)); } this.save('connection_status', converse.ROOMSTATUS.CONNECTING); _converse.api.send(stanza); return this; }, /** * Sends an IQ stanza to the XMPP server to destroy this groupchat. Not * to be confused with the {@link _converse.ChatRoom#destroy} * method, which simply removes the room from the local browser storage cache. * @private * @method _converse.ChatRoom#sendDestroyIQ * @param { string } [reason] - The reason for destroying the groupchat * @param { string } [new_jid] - The JID of the new groupchat which * replaces this one. */ sendDestroyIQ (reason, new_jid) { const destroy = $build("destroy"); if (new_jid) { destroy.attrs({'jid': new_jid}); } const iq = $iq({ 'to': this.get('jid'), 'type': "set" }).c("query", {'xmlns': Strophe.NS.MUC_OWNER}).cnode(destroy.node); if (reason && reason.length > 0) { iq.c("reason", reason); } return _converse.api.sendIQ(iq); }, /** * Leave the groupchat. * @private * @method _converse.ChatRoom#leave * @param { string } [exit_msg] - Message to indicate your reason for leaving */ leave (exit_msg) { this.features.destroy(); this.occupants.browserStorage._clear(); this.occupants.reset(); if (_converse.disco_entities) { const disco_entity = _converse.disco_entities.get(this.get('jid')); if (disco_entity) { disco_entity.destroy(); } } if (_converse.connection.connected) { this.sendUnavailablePresence(exit_msg); } u.safeSave(this, {'connection_status': converse.ROOMSTATUS.DISCONNECTED}); this.removeHandlers(); }, close () { try { this.features.destroy(); this.features.browserStorage._clear(); } catch (e) { _converse.log(e, Strophe.LogLevel.ERROR); } return _converse.ChatBox.prototype.close.call(this); }, sendUnavailablePresence (exit_msg) { const presence = $pres({ type: "unavailable", from: _converse.connection.jid, to: this.getRoomJIDAndNick() }); if (exit_msg !== null) { presence.c("status", exit_msg); } _converse.connection.sendPresence(presence); }, getReferenceForMention (mention, index) { const longest_match = u.getLongestSubstring( mention, this.occupants.map(o => o.getDisplayName()) ); if (!longest_match) { return null; } if ((mention[longest_match.length] || '').match(/[A-Za-zäëïöüâêîôûáéíóúàèìòùÄËÏÖÜÂÊÎÔÛÁÉÍÓÚÀÈÌÒÙ0-9]/i)) { // avoid false positives, i.e. mentions that have // further alphabetical characters than our longest // match. return null; } const occupant = this.occupants.findOccupant({'nick': longest_match}) || this.occupants.findOccupant({'jid': longest_match}); if (!occupant) { return null; } const obj = { 'begin': index, 'end': index + longest_match.length, 'value': longest_match, 'type': 'mention' }; if (occupant.get('jid')) { obj.uri = `xmpp:${occupant.get('jid')}`; } else { obj.uri = `xmpp:${this.get('jid')}/${occupant.get('nick')}`; } return obj; }, extractReference (text, index) { for (let i=index; i<text.length; i++) { if (text[i] === '@' && (i === 0 || text[i - 1] === ' ')) { const match = text.slice(i+1), ref = this.getReferenceForMention(match, i); if (ref) { return [text.slice(0, i) + match, ref, i] } } } return; }, parseTextForReferences (text) { const refs = []; let index = 0; while (index < (text || '').length) { const result = this.extractReference(text, index); if (result) { text = result[0]; // @ gets filtered out refs.push(result[1]); index = result[2]; } else { break; } } return [text, refs]; }, getOutgoingMessageAttributes (text, spoiler_hint) { const is_spoiler = this.get('composing_spoiler'); var references; [text, references] = this.parseTextForReferences(text); const origin_id = _converse.connection.getUniqueId(); return { 'id': origin_id, 'msgid': origin_id, 'origin_id': origin_id, 'from': `${this.get('jid')}/${this.get('nick')}`, 'fullname': this.get('nick'), 'is_single_emoji': text ? u.isOnlyEmojis(text) : false, 'is_spoiler': is_spoiler, 'message': text ? u.httpToGeoUri(u.shortnameToUnicode(text), _converse) : undefined, 'nick': this.get('nick'), 'references': references, 'sender': 'me', 'spoiler_hint': is_spoiler ? spoiler_hint : undefined, 'type': 'groupchat' }; }, /** * Utility method to construct the JID for the current user * as occupant of the groupchat. * * @returns {string} - The groupchat JID with the user's nickname added at the end. * @example groupchat@conference.example.org/nickname */ getRoomJIDAndNick () { const nick = this.get('nick'); const jid = Strophe.getBareJidFromJid(this.get('jid')); return jid + (nick !== null ? `/${nick}` : ""); }, /** * Sends a message with the current XEP-0085 chat state of the user * as taken from the `chat_state` attribute of the {@link _converse.ChatRoom}. * @private * @method _converse.ChatRoom#sendChatState */ sendChatState () { if (!_converse.send_chat_state_notifications || !this.get('chat_state') || this.get('connection_status') !== converse.ROOMSTATUS.ENTERED || this.features.get('moderated') && this.getOwnRole() === 'visitor') { return; } const allowed = _converse.send_chat_state_notifications; if (Array.isArray(allowed) && !allowed.includes(this.get('chat_state'))) { return; } const chat_state = this.get('chat_state'); if (chat_state === _converse.GONE) { // <gone/> is not applicable within MUC context return; } _converse.api.send( $msg({'to':this.get('jid'), 'type': 'groupchat'}) .c(chat_state, {'xmlns': Strophe.NS.CHATSTATES}).up() .c('no-store', {'xmlns': Strophe.NS.HINTS}).up() .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS}) ); }, /** * Send a direct invitation as per XEP-0249 * @private * @method _converse.ChatRoom#directInvite * @param { String } recipient - JID of the person being invited * @param { String } [reason] - Reason for the invitation */ directInvite (recipient, reason) { if (this.features.get('membersonly')) { // When inviting to a members-only groupchat, we first add // the person to the member list by giving them an // affiliation of 'member' otherwise they won't be able to join. this.updateMemberLists([{'jid': recipient, 'affiliation': 'member', 'reason': reason}]); } 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.api.send(invitation); /** * 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 * @example _converse.api.listen.on('chatBoxMaximized', view => { ... }); */ _converse.api.trigger('roomInviteSent', { 'room': this, 'recipient': recipient, 'reason': reason }); }, async refreshRoomFeatures () { await _converse.api.disco.refreshFeatures(this.get('jid')); return this.getRoomFeatures(); }, 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 _converse.log(e, Strophe.LogLevel.ERROR); } 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 features = await _converse.api.disco.getFeatures(this.get('jid')); const attrs = Object.assign( _.zipObject(converse.ROOM_FEATURES, converse.ROOM_FEATURES.map(_.stubFalse)), {'fetched': (new Date()).toISOString()} ); features.each(feature => { const fieldname = feature.get('var'); if (!fieldname.startsWith('muc_')) { if (fieldname === Strophe.NS.MAM) { attrs.mam_enabled = true; } return; } attrs[fieldname.replace('muc_', '')] = true; }); this.features.save(attrs); }, /** * Send IQ stanzas to the server to set an affiliation for * the provided JIDs. * See: https://xmpp.org/extensions/xep-0045.html#modifymember * * 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://issues.prosody.im/345 * * @private * @method _converse.ChatRoom#setAffiliation * @param { string } affiliation - The affiliation * @param { 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 { Promise } A promise which resolves and fails depending on the XMPP server response. */ setAffiliation (affiliation, members) { members = members.filter(m => m.affiliation === undefined || m.affiliation === affiliation); 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. * @private * @method _converse.ChatRoom#addFieldValue * @returns { Element } */ addFieldValue (field) { const type = field.getAttribute('type'); if (type === 'fixed') { return field; } const fieldname = field.getAttribute('var').replace('muc#roomconfig_', ''); const config = this.get('roomconfig'); if (fieldname in config) { let values; switch (type) { case 'boolean': values = [config[fieldname] ? 1 : 0]; break; case 'list-multi': values = config[fieldname]; break; default: values= [config[fieldname]]; } field.innerHTML = values.map(v => $build('value').t(v)).join(''); } return field; }, /** * Automatically configure the groupchat based on this model's * 'roomconfig' data. * @private * @method _converse.ChatRoom#autoConfigureChatRoom * @returns { Promise<XMLElement> } * Returns a promise which resolves once a response IQ has * been received. */ async autoConfigureChatRoom () { const stanza = await this.fetchRoomConfiguration(); const fields = sizzle('field', stanza); const configArray = fields.map(f => this.addFieldValue(f)) if (configArray.length) { return this.sendConfiguration(configArray); } }, /** * Send an IQ stanza to fetch the groupchat configuration data. * Returns a promise which resolves once the response IQ * has been received. * @private * @method _converse.ChatRoom#fetchRoomConfiguration * @returns { Promise<XMLElement> } */ fetchRoomConfiguration () { return _converse.api.sendIQ( $iq({'to': this.get('jid'), 'type': "get"}) .c("query", {xmlns: Strophe.NS.MUC_OWNER}) ); }, /** * Sends an IQ stanza with the groupchat configuration. * @private * @method _converse.ChatRoom#sendConfiguration * @param { Array } config - The groupchat configuration * @returns { Promise<XMLElement> } - A promise which resolves with * the `result` stanza received from the XMPP server. */ sendConfiguration (config=[]) { const iq = $iq({to: this.get('jid'), type: "set"}) .c("query", {xmlns: Strophe.NS.MUC_OWNER}) .c("x", {xmlns: Strophe.NS.XFORM, type: "submit"}); config.forEach(node => iq.cnode(node).up()); return _converse.api.sendIQ(iq); }, /** * Returns the `role` which the current user has in this MUC * @private * @method _converse.ChatRoom#getOwnRole * @returns { ('none'|'visitor'|'participant'|'moderator') } */ getOwnRole () { return _.get(this.getOwnOccupant(), 'attributes.role'); }, /** * Returns the `affiliation` which the current user has in this MUC * @private * @method _converse.ChatRoom#getOwnAffiliation * @returns { ('none'|'outcast'|'member'|'admin'|'owner') } */ getOwnAffiliation () { return _.get(this.getOwnOccupant(), 'attributes.affiliation'); }, /** * Get the {@link _converse.ChatRoomOccupant} instance which * represents the current user. * @private * @method _converse.ChatRoom#getOwnOccupant * @returns { _converse.ChatRoomOccupant } */ getOwnOccupant () { return this.occupants.findWhere({'jid': _converse.bare_jid}); }, /** * Parse the presence stanza for the current user's affiliation and * role and save them on the relevant {@link _converse.ChatRoomOccupant} * instance. * @private * @method _converse.ChatRoom#saveAffiliationAndRole * @param { XMLElement } pres - A <presence> stanza. */ saveAffiliationAndRole (pres) { const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, pres).pop(); const is_self = (pres.querySelector("status[code='110']") !== null); if (is_self && item) { const affiliation = item.getAttribute('affiliation'); const role = item.getAttribute('role'); const changes = {}; if (affiliation) { changes['affiliation'] = affiliation; } if (role) { changes['role'] = role; } if (!_.isEmpty(changes)) { this.getOwnOccupant().save(changes); } } }, /** * Send an IQ stanza specifying an affiliation change. * @private * @method _converse.ChatRoom# * @param { String } affiliation: affiliation * (could also be stored on the member object). * @param { Object } member: Map containing the member's jid and * optionally a reason and affiliation. */ sendAffiliationIQ (affiliation, member) { const iq = $iq({to: this.get('jid'), type: "set"}) .c("query", {xmlns: Strophe.NS.MUC_ADMIN}) .c("item", { 'affiliation': member.affiliation || affiliation, 'nick': member.nick, 'jid': member.jid }); if (member.reason !== undefined) { iq.c("reason", member.reason); } return _converse.api.sendIQ(iq); }, /** * Send IQ stanzas to the server to modify affiliations for users in this groupchat. * * See: https://xmpp.org/extensions/xep-0045.html#modifymember * @private * @method _converse.ChatRoom#setAffiliations * @param { Object[] } members * @param { string } members[].jid - The JID of the user whose affiliation will change * @param { Array } members[].affiliation - The new affiliation for this user * @param { string } [members[].reason] - An optional reason for the affiliation change * @returns { Promise } */ setAffiliations (members) { const affiliations = _.uniq(members.map(m => m.affiliation)); return Promise.all(affiliations.map(a => this.setAffiliation(a, members))); }, /** * Send an IQ stanza to modify an occupant's role * @private * @method _converse.ChatRoom#setRole * @param { _converse.ChatRoomOccupant } occupant * @param { String } role * @param { String } reason * @param { function } onSuccess - callback for a succesful response * @param { function } onError - callback for an error response */ setRole (occupant, role, reason, onSuccess, onError) { const item = $build("item", { 'nick': occupant.get('nick'), role }); const iq = $iq({ 'to': this.get('jid'), 'type': 'set' }).c("query", {xmlns: Strophe.NS.MUC_ADMIN}).cnode(item.node); if (reason !== null) { iq.c("reason", reason); } return _converse.api.sendIQ(iq).then(onSuccess).catch(onError); }, /** * @private * @method _converse.ChatRoom#getOccupant * @param { String } nick_or_jid - The nickname or JID of the occupant to be returned * @returns { _converse.ChatRoomOccupant } */ getOccupant (nick_or_jid) { return (u.isValidJID(nick_or_jid) && this.occupants.findWhere({'jid': nick_or_jid})) || this.occupants.findWhere({'nick': nick_or_jid}); }, /** * Sends an IQ stanza to the server, asking it for the relevant affiliation list . * Returns an array of {@link MemberListItem} objects, representing occupants * that have the given affiliation. * See: https://xmpp.org/extensions/xep-0045.html#modifymember