// Converse.js (A browser based XMPP chat client) // http://conversejs.org // // Copyright (c) 2012-2017, Jan-Carel Brand // Licensed under the Mozilla Public License (MPLv2) // /*global Backbone, define */ /* This is a Converse.js plugin which add support for multi-user chat rooms, as * specified in XEP-0045 Multi-user chat. */ (function (root, factory) { define([ "converse-core", "tpl!chatarea", "tpl!chatroom", "tpl!chatroom_form", "tpl!chatroom_nickname_form", "tpl!chatroom_password_form", "tpl!chatroom_sidebar", "tpl!chatroom_toolbar", "tpl!chatroom_head", "tpl!chatrooms_tab", "tpl!info", "tpl!occupant", "tpl!room_description", "tpl!room_item", "tpl!room_panel", "awesomplete", "converse-chatview" ], factory); }(this, function ( converse, tpl_chatarea, tpl_chatroom, tpl_chatroom_form, tpl_chatroom_nickname_form, tpl_chatroom_password_form, tpl_chatroom_sidebar, tpl_chatroom_toolbar, tpl_chatroom_head, tpl_chatrooms_tab, tpl_info, tpl_occupant, tpl_room_description, tpl_room_item, tpl_room_panel, Awesomplete ) { "use strict"; var ROOMS_PANEL_ID = 'chatrooms'; // Strophe methods for building stanzas var Strophe = converse.env.Strophe, $iq = converse.env.$iq, $build = converse.env.$build, $msg = converse.env.$msg, $pres = converse.env.$pres, b64_sha1 = converse.env.b64_sha1, utils = converse.env.utils; // Other necessary globals var $ = converse.env.jQuery, _ = converse.env._, moment = converse.env.moment; // 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.plugins.add('converse-muc', { /* Optional dependencies are other plugins which might be * overridden or relied upon, if they exist, otherwise they're ignored. * * However, 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. */ optional_dependencies: ["converse-controlbox"], overrides: { // Overrides mentioned here will be picked up by converse.js's // plugin architecture they will replace existing methods on the // relevant objects or classes. // // New functions which don't exist yet can also be added. Features: { addClientFeatures: function () { var _converse = this.__super__._converse; this.__super__.addClientFeatures.apply(this, arguments); if (_converse.allow_muc_invitations) { _converse.connection.disco.addFeature('jabber:x:conference'); // Invites } if (_converse.allow_muc) { _converse.connection.disco.addFeature(Strophe.NS.MUC); } } }, ControlBoxView: { renderContactsPanel: function () { var _converse = this.__super__._converse; this.__super__.renderContactsPanel.apply(this, arguments); if (_converse.allow_muc) { this.roomspanel = new _converse.RoomsPanel({ '$parent': this.$el.find('.controlbox-panes'), 'model': new (Backbone.Model.extend({ id: b64_sha1('converse.roomspanel'+_converse.bare_jid), // Required by sessionStorage browserStorage: new Backbone.BrowserStorage[_converse.storage]( b64_sha1('converse.roomspanel'+_converse.bare_jid)) }))() }); this.roomspanel.render().model.fetch(); if (!this.roomspanel.model.get('nick')) { this.roomspanel.model.save({ nick: Strophe.getNodeFromJid(_converse.bare_jid) }); } } }, onConnected: function () { var _converse = this.__super__._converse; this.__super__.onConnected.apply(this, arguments); if (!this.model.get('connected')) { return; } if (_.isUndefined(_converse.muc_domain)) { _converse.features.off('add', this.featureAdded, this); _converse.features.on('add', this.featureAdded, this); // Features could have been added before the controlbox was // initialized. We're only interested in MUC var feature = _converse.features.findWhere({ 'var': Strophe.NS.MUC }); if (feature) { this.featureAdded(feature); } } else { this.setMUCDomain(_converse.muc_domain); } }, setMUCDomain: function (domain) { this.roomspanel.model.save({'muc_domain': domain}); var $server= this.$el.find('input.new-chatroom-server'); if (!$server.is(':focus')) { $server.val(this.roomspanel.model.get('muc_domain')); } }, featureAdded: function (feature) { var _converse = this.__super__._converse; if ((feature.get('var') === Strophe.NS.MUC) && (_converse.allow_muc)) { this.setMUCDomain(feature.get('from')); } } }, ChatBoxViews: { onChatBoxAdded: function (item) { var _converse = this.__super__._converse; var view = this.get(item.get('id')); if (!view && item.get('type') === 'chatroom') { view = new _converse.ChatRoomView({'model': item}); return this.add(item.get('id'), view); } else { return this.__super__.onChatBoxAdded.apply(this, arguments); } } } }, initialize: function () { /* The initialize function gets called as soon as the plugin is * loaded by converse.js's plugin machinery. */ var _converse = this._converse, __ = _converse.__, ___ = _converse.___; _converse.templates.chatarea = tpl_chatarea; _converse.templates.chatroom = tpl_chatroom; _converse.templates.chatroom_form = tpl_chatroom_form; _converse.templates.chatroom_nickname_form = tpl_chatroom_nickname_form; _converse.templates.chatroom_password_form = tpl_chatroom_password_form; _converse.templates.chatroom_sidebar = tpl_chatroom_sidebar; _converse.templates.chatroom_head = tpl_chatroom_head; _converse.templates.chatrooms_tab = tpl_chatrooms_tab; _converse.templates.info = tpl_info; _converse.templates.occupant = tpl_occupant; _converse.templates.room_description = tpl_room_description; _converse.templates.room_item = tpl_room_item; _converse.templates.room_panel = tpl_room_panel; // XXX: Inside plugins, all calls to the translation machinery // (e.g. utils.__) should only be done in the initialize function. // If called before, we won't know what language the user wants, // and it'll fallback to English. /* http://xmpp.org/extensions/xep-0045.html * ---------------------------------------- * 100 message Entering a room 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 room * 102 message Configuration change Inform occupants that room now shows unavailable members * 103 message Configuration change Inform occupants that room now does not show unavailable members * 104 message Configuration change Inform occupants that a non-privacy-related room configuration change has occurred * 110 presence Any room presence Inform user that presence refers to one of its own room occupants * 170 message or initial presence Configuration change Inform occupants that room logging is now enabled * 171 message Configuration change Inform occupants that room logging is now disabled * 172 message Configuration change Inform occupants that the room is now non-anonymous * 173 message Configuration change Inform occupants that the room is now semi-anonymous * 174 message Configuration change Inform occupants that the room is now fully-anonymous * 201 presence Entering a room Inform user that a new room has been created * 210 presence Entering a room Inform user that the service has assigned or modified the occupant's roomnick * 301 presence Removal from room Inform user that he or she has been banned from the room * 303 presence Exiting a room Inform all occupants of new room nickname * 307 presence Removal from room Inform user that he or she has been kicked from the room * 321 presence Removal from room Inform user that he or she is being removed from the room because of an affiliation change * 322 presence Removal from room Inform user that he or she is being removed from the room because the room has been changed to members-only and the user is not a member * 332 presence Removal from room Inform user that he or she is being removed from the room because of a system shutdown */ _converse.muc = { info_messages: { 100: __('This room is not anonymous'), 102: __('This room now shows unavailable members'), 103: __('This room does not show unavailable members'), 104: __('The room configuration has changed'), 170: __('Room logging is now enabled'), 171: __('Room logging is now disabled'), 172: __('This room is now no longer anonymous'), 173: __('This room is now semi-anonymous'), 174: __('This room is now fully-anonymous'), 201: __('A new room has been created') }, disconnect_messages: { 301: __('You have been banned from this room'), 307: __('You have been kicked from this room'), 321: __("You have been removed from this room because of an affiliation change"), 322: __("You have been removed from this room because the room has changed to members-only and you're not a member"), 332: __("You have been removed from this room because the MUC (Multi-user chat) service is being shut down.") }, action_info_messages: { /* XXX: Note the triple underscore function and not double * underscore. * * This is a hack. We can't pass the strings to __ because we * don't yet know what the variable to interpolate is. * * Triple underscore will just return the string again, but we * can then at least tell gettext to scan for it so that these * strings are picked up by the translation machinery. */ 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") }, new_nickname_messages: { 210: ___('Your nickname has been automatically set to: %1$s'), 303: ___('Your nickname has been changed to: %1$s') } }; // Configuration values for this plugin // ==================================== // Refer to docs/source/configuration.rst for explanations of these // configuration settings. this.updateSettings({ allow_muc: true, allow_muc_invitations: true, auto_join_on_invite: false, auto_join_rooms: [], auto_list_rooms: false, hide_muc_server: false, muc_disable_moderator_commands: false, muc_domain: undefined, muc_history_max_stanzas: undefined, muc_instant_rooms: true, muc_nickname_from_jid: false, visible_toolbar_buttons: { 'toggle_occupants': true }, }); _converse.createChatRoom = function (settings) { /* Creates a new chat room, making sure that certain attributes * are correct, for example that the "type" is set to * "chatroom". */ return _converse.chatboxviews.showChat( _.extend(settings, { 'type': 'chatroom', 'affiliation': null, 'features_fetched': false, 'hidden': false, 'membersonly': false, 'moderated': false, 'nonanonymous': false, 'open': false, 'passwordprotected': false, 'persistent': false, 'public': false, 'semianonymous': false, 'temporary': false, 'unmoderated': false, 'unsecured': false, 'connection_status': Strophe.Status.DISCONNECTED }) ); }; _converse.ChatRoomView = _converse.ChatBoxView.extend({ /* Backbone View which renders a chat room, based upon the view * for normal one-on-one chat boxes. */ length: 300, tagName: 'div', className: 'chatbox chatroom hidden', is_chatroom: true, events: { 'click .close-chatbox-button': 'close', 'click .configure-chatroom-button': 'configureChatRoom', 'click .toggle-smiley': 'toggleEmoticonMenu', 'click .toggle-smiley ul li': 'insertEmoticon', 'click .toggle-clear': 'clearChatRoomMessages', 'click .toggle-call': 'toggleCall', 'click .toggle-occupants a': 'toggleOccupants', 'click .new-msgs-indicator': 'viewUnreadMessages', 'click .occupant': 'onOccupantClicked', 'keypress .chat-textarea': 'keyPressed' }, initialize: function () { var that = this; this.model.messages.on('add', this.onMessageAdded, this); this.model.on('show', this.show, this); this.model.on('destroy', this.hide, this); this.model.on('change:chat_state', this.sendChatState, this); this.model.on('change:affiliation', this.renderHeading, this); this.model.on('change:name', this.renderHeading, this); this.createOccupantsView(); this.render().insertIntoDOM(); // TODO: hide chat area until messages received. // XXX: adding the event below to the declarative events map doesn't work. // The code that gets executed because of that looks like this: // this.$el.on('scroll', '.chat-content', this.markScrolled.bind(this)); // Which for some reason doesn't work. // So working around that fact here: this.$el.find('.chat-content').on('scroll', this.markScrolled.bind(this)); this.getRoomFeatures().always(function () { that.join(); that.fetchMessages(); _converse.emit('chatRoomOpened', that); }); }, createOccupantsView: function () { /* Create the ChatRoomOccupantsView Backbone.View */ this.occupantsview = new _converse.ChatRoomOccupantsView({ model: new _converse.ChatRoomOccupants() }); var id = b64_sha1('converse.occupants'+_converse.bare_jid+this.model.get('jid')); this.occupantsview.model.browserStorage = new Backbone.BrowserStorage.session(id); this.occupantsview.chatroomview = this; this.occupantsview.render(); this.occupantsview.model.fetch({add:true}); }, insertIntoDOM: function () { var view = _converse.chatboxviews.get("controlbox"); if (view) { this.$el.insertAfter(view.$el); } else { $('#conversejs').prepend(this.$el); } return this; }, render: function () { this.$el.attr('id', this.model.get('box_id')) .html(_converse.templates.chatroom()); this.renderHeading(); this.renderChatArea(); utils.refreshWebkit(); return this; }, generateHeadingHTML: function () { /* Pure function which returns the heading HTML to be * rendered. */ return _converse.templates.chatroom_head( _.extend(this.model.toJSON(), { info_close: __('Close and leave this room'), info_configure: __('Configure this room'), })); }, renderHeading: function () { /* Render the heading UI of the chat room. */ this.el.querySelector('.chat-head-chatroom').innerHTML = this.generateHeadingHTML(); }, renderChatArea: function () { /* Render the UI container in which chat room messages will * appear. */ if (!this.$('.chat-area').length) { this.$('.chatroom-body').empty() .append( _converse.templates.chatarea({ 'unread_msgs': __('You have unread messages'), 'show_toolbar': _converse.show_toolbar, 'label_message': __('Message') })) .append(this.occupantsview.$el); this.renderToolbar(tpl_chatroom_toolbar); this.$content = this.$el.find('.chat-content'); } this.toggleOccupants(null, true); return this; }, getExtraMessageClasses: function (attrs) { var extra_classes = _converse.ChatBoxView.prototype .getExtraMessageClasses.apply(this, arguments); if (this.is_chatroom && attrs.sender === 'them' && (new RegExp("\\b"+this.model.get('nick')+"\\b")).test(attrs.message) ) { // Add special class to mark groupchat messages // in which we are mentioned. extra_classes += ' mentioned'; } return extra_classes; }, getToolbarOptions: function () { return _.extend( _converse.ChatBoxView.prototype.getToolbarOptions.apply(this, arguments), { label_hide_occupants: __('Hide the list of occupants'), show_occupants_toggle: this.is_chatroom && _converse.visible_toolbar_buttons.toggle_occupants } ); }, close: function (ev) { /* Close this chat box, which implies leaving the room as * well. */ this.leave(); }, toggleOccupants: function (ev, preserve_state) { /* Show or hide the right sidebar containing the chat * occupants (and the invite widget). */ if (ev) { ev.preventDefault(); ev.stopPropagation(); } if (preserve_state) { // Bit of a hack, to make sure that the sidebar's state doesn't change this.model.set({hidden_occupants: !this.model.get('hidden_occupants')}); } if (!this.model.get('hidden_occupants')) { this.model.save({hidden_occupants: true}); this.$('.icon-hide-users').removeClass('icon-hide-users').addClass('icon-show-users'); this.$('.occupants').addClass('hidden'); this.$('.chat-area').addClass('full'); this.scrollDown(); } else { this.model.save({hidden_occupants: false}); this.$('.icon-show-users').removeClass('icon-show-users').addClass('icon-hide-users'); this.$('.chat-area').removeClass('full'); this.$('div.occupants').removeClass('hidden'); this.scrollDown(); } }, onOccupantClicked: function (ev) { /* When an occupant is clicked, insert their nickname into * the chat textarea input. */ this.insertIntoTextArea(ev.target.textContent); }, requestMemberList: function (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. */ var deferred = new $.Deferred(); affiliation = affiliation || 'member'; var iq = $iq({to: chatroom_jid, type: "get"}) .c("query", {xmlns: Strophe.NS.MUC_ADMIN}) .c("item", {'affiliation': affiliation}); _converse.connection.sendIQ(iq, deferred.resolve, deferred.reject); return deferred.promise(); }, parseMemberListIQ: function (iq) { /* Given an IQ stanza with a member list, create an array of member * objects. */ return _.map( $(iq).find('query[xmlns="'+Strophe.NS.MUC_ADMIN+'"] item'), function (item) { return { 'jid': item.getAttribute('jid'), 'affiliation': item.getAttribute('affiliation'), }; } ); }, computeAffiliationsDelta: function (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 */ var new_jids = _.map(new_list, 'jid'); var old_jids = _.map(old_list, 'jid'); // Get the new affiliations var delta = _.map(_.difference(new_jids, old_jids), function (jid) { return new_list[_.indexOf(new_jids, jid)]; }); if (!exclude_existing) { // Get the changed affiliations delta = delta.concat(_.filter(new_list, function (item) { var 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), function (jid) { return {'jid': jid, 'affiliation': 'none'}; })); } return delta; }, sendAffiliationIQ: function (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. */ var deferred = new $.Deferred(); var 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, deferred.resolve, deferred.reject); return deferred; }, setAffiliation: function (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, function (member) { // We only want those members who have the right // affiliation (or none, which implies the provided // one). return _.isUndefined(member.affiliation) || member.affiliation === affiliation; }); var promises = _.map( members, _.partial(this.sendAffiliationIQ, this.model.get('jid'), affiliation) ); return $.when.apply($, promises); }, setAffiliations: function (members, onSuccess, onError) { /* 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 */ if (_.isEmpty(members)) { // Succesfully updated with zero affilations :) onSuccess(null); return; } var affiliations = _.uniq(_.map(members, 'affiliation')); var promises = _.map(affiliations, _.partial(this.setAffiliation.bind(this), _, members)); $.when.apply($, promises).done(onSuccess).fail(onError); }, marshallAffiliationIQs: function () { /* 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, this.parseMemberListIQ); }, getJidsWithAffiliations: function (affiliations) { /* Returns a map of JIDs that have the affiliations * as provided. */ if (_.isString(affiliations)) { affiliations = [affiliations]; } var deferred = new $.Deferred(); var promises = _.map(affiliations, _.partial(this.requestMemberList, this.model.get('jid'))); $.when.apply($, promises).always( _.flow(this.marshallAffiliationIQs.bind(this), deferred.resolve) ); return deferred.promise(); }, updateMemberLists: function (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. */ var that = this; var deferred = new $.Deferred(); this.getJidsWithAffiliations(affiliations).then(function (old_members) { that.setAffiliations( deltaFunc(members, old_members), deferred.resolve, deferred.reject ); }); return deferred.promise(); }, directInvite: function (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. var map = {}; map[recipient] = 'member'; var deltaFunc = _.partial(this.computeAffiliationsDelta, true, false); this.updateMemberLists( [{'jid': recipient, 'affiliation': 'member', 'reason': reason}], ['member', 'owner', 'admin'], deltaFunc ); } var 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'); } var 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: function (message) { /* Override the method on the ChatBoxView base class to * ignore notifications in groupchats. * * As laid out in the business rules in XEP-0085 * http://xmpp.org/extensions/xep-0085.html#bizrules-groupchat */ if (message.get('fullname') === this.model.get('nick')) { // Don't know about other servers, but OpenFire sends // back to you your own chat state notifications. // We ignore them here... return; } if (message.get('chat_state') !== _converse.GONE) { _converse.ChatBoxView.prototype.handleChatStateMessage.apply(this, arguments); } }, sendChatState: function () { /* Sends a message with the status of the user in this chat session * as taken from the 'chat_state' attribute of the chat box. * See XEP-0085 Chat State Notifications. */ var chat_state = this.model.get('chat_state'); if (chat_state === _converse.GONE) { // is not applicable within MUC context return; } _converse.connection.send( $msg({'to':this.model.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}) ); }, sendChatRoomMessage: function (text) { /* Constuct a message stanza to be sent to this chat room, * and send it to the server. * * Parameters: * (String) text: The message text to be sent. */ var msgid = _converse.connection.getUniqueId(); var msg = $msg({ to: this.model.get('jid'), from: _converse.connection.jid, type: 'groupchat', id: msgid }).c("body").t(text).up() .c("x", {xmlns: "jabber:x:event"}).c(_converse.COMPOSING); _converse.connection.send(msg); this.model.messages.create({ fullname: this.model.get('nick'), sender: 'me', time: moment().format(), message: text, msgid: msgid }); }, modifyRole: function(room, nick, role, reason, onSuccess, onError) { var item = $build("item", {nick: nick, role: role}); var iq = $iq({to: room, type: "set"}).c("query", {xmlns: Strophe.NS.MUC_ADMIN}).cnode(item.node); if (reason !== null) { iq.c("reason", reason); } return _converse.connection.sendIQ(iq.tree(), onSuccess, onError); }, validateRoleChangeCommand: function (command, args) { /* Check that a command to change a chat room user's role or * affiliation has anough arguments. */ // TODO check if first argument is valid if (args.length < 1 || args.length > 2) { this.showStatusNotification( __("Error: the \""+command+"\" command takes two arguments, the user's nickname and optionally a reason."), true ); return false; } return true; }, clearChatRoomMessages: function (ev) { /* Remove all messages from the chat room UI. */ if (!_.isUndefined(ev)) { ev.stopPropagation(); } var result = confirm(__("Are you sure you want to clear the messages from this room?")); if (result === true) { this.$content.empty(); } return this; }, onCommandError: function () { this.showStatusNotification(__("Error: could not execute the command"), true); }, onMessageSubmitted: function (text) { /* Gets called when the user presses enter to send off a * message in a chat room. * * Parameters: * (String) text - The message text. */ if (_converse.muc_disable_moderator_commands) { return this.sendChatRoomMessage(text); } var match = text.replace(/^\s*/, "").match(/^\/(.*?)(?: (.*))?$/) || [false, '', ''], args = match[2] && match[2].splitOnce(' ') || [], command = match[1].toLowerCase(); switch (command) { case 'admin': if (!this.validateRoleChangeCommand(command, args)) { break; } this.setAffiliation('admin', [{ 'jid': args[0], 'reason': args[1] }]).fail(this.onCommandError.bind(this)); break; case 'ban': if (!this.validateRoleChangeCommand(command, args)) { break; } this.setAffiliation('outcast', [{ 'jid': args[0], 'reason': args[1] }]).fail(this.onCommandError.bind(this)); break; case 'clear': this.clearChatRoomMessages(); break; case 'deop': if (!this.validateRoleChangeCommand(command, args)) { break; } this.modifyRole( this.model.get('jid'), args[0], 'occupant', args[1], undefined, this.onCommandError.bind(this)); break; case 'help': this.showHelpMessages([ '/admin: ' +__("Change user's affiliation to admin"), '/ban: ' +__('Ban user from room'), '/clear: ' +__('Remove messages'), '/deop: ' +__('Change user role to occupant'), '/help: ' +__('Show this menu'), '/kick: ' +__('Kick user from room'), '/me: ' +__('Write in 3rd person'), '/member: ' +__('Grant membership to a user'), '/mute: ' +__("Remove user's ability to post messages"), '/nick: ' +__('Change your nickname'), '/op: ' +__('Grant moderator role to user'), '/owner: ' +__('Grant ownership of this room'), '/revoke: ' +__("Revoke user's membership"), '/subject: ' +__('Set room subject'), '/topic: ' +__('Set room subject (alias for /subject)'), '/voice: ' +__('Allow muted user to post messages') ]); break; case 'kick': if (!this.validateRoleChangeCommand(command, args)) { break; } this.modifyRole( this.model.get('jid'), args[0], 'none', args[1], undefined, this.onCommandError.bind(this)); break; case 'mute': if (!this.validateRoleChangeCommand(command, args)) { break; } this.modifyRole( this.model.get('jid'), args[0], 'visitor', args[1], undefined, this.onCommandError.bind(this)); break; case 'member': if (!this.validateRoleChangeCommand(command, args)) { break; } this.setAffiliation('member', [{ 'jid': args[0], 'reason': args[1] }]).fail(this.onCommandError.bind(this)); break; case 'nick': _converse.connection.send($pres({ from: _converse.connection.jid, to: this.getRoomJIDAndNick(match[2]), id: _converse.connection.getUniqueId() }).tree()); break; case 'owner': if (!this.validateRoleChangeCommand(command, args)) { break; } this.setAffiliation('owner', [{ 'jid': args[0], 'reason': args[1] }]).fail(this.onCommandError.bind(this)); break; case 'op': if (!this.validateRoleChangeCommand(command, args)) { break; } this.modifyRole( this.model.get('jid'), args[0], 'moderator', args[1], undefined, this.onCommandError.bind(this)); break; case 'revoke': if (!this.validateRoleChangeCommand(command, args)) { break; } this.setAffiliation('none', [{ 'jid': args[0], 'reason': args[1] }]).fail(this.onCommandError.bind(this)); break; case 'topic': case 'subject': _converse.connection.send( $msg({ to: this.model.get('jid'), from: _converse.connection.jid, type: "groupchat" }).c("subject", {xmlns: "jabber:client"}).t(match[2]).tree() ); break; case 'voice': if (!this.validateRoleChangeCommand(command, args)) { break; } this.modifyRole( this.model.get('jid'), args[0], 'occupant', args[1], undefined, this.onCommandError.bind(this)); break; default: this.sendChatRoomMessage(text); break; } }, handleMUCMessage: function (stanza) { /* Handler for all MUC messages sent to this chat room. * * MAM (message archive management XEP-0313) messages are * ignored, since they're handled separately. * * Parameters: * (XMLElement) stanza: The message stanza. */ var is_mam = $(stanza).find('[xmlns="'+Strophe.NS.MAM+'"]').length > 0; if (is_mam) { return true; } var configuration_changed = stanza.querySelector("status[code='104']"); var logging_enabled = stanza.querySelector("status[code='170']"); var logging_disabled = stanza.querySelector("status[code='171']"); var room_no_longer_anon = stanza.querySelector("status[code='172']"); var room_now_semi_anon = stanza.querySelector("status[code='173']"); var room_now_fully_anon = stanza.querySelector("status[code='173']"); if (configuration_changed || logging_enabled || logging_disabled || room_no_longer_anon || room_now_semi_anon || room_now_fully_anon) { this.getRoomFeatures(); } _.flow(this.showStatusMessages.bind(this), this.onChatRoomMessage.bind(this))(stanza); return true; }, getRoomJIDAndNick: function (nick) { /* Utility method to construct the JID for the current user * as occupant of the room. * * This is the room JID, with the user's nick added at the * end. * * For example: room@conference.example.org/nickname */ if (nick) { this.model.save({'nick': nick}); } else { nick = this.model.get('nick'); } var room = this.model.get('jid'); var node = Strophe.getNodeFromJid(room); var domain = Strophe.getDomainFromJid(room); return node + "@" + domain + (nick !== null ? "/" + nick : ""); }, registerHandlers: function () { /* Register presence and message handlers for this chat * room */ var room_jid = this.model.get('jid'); this.removeHandlers(); this.presence_handler = _converse.connection.addHandler( this.onChatRoomPresence.bind(this), Strophe.NS.MUC, 'presence', null, null, room_jid, {'ignoreNamespaceFragment': true, 'matchBareFromJid': true} ); this.message_handler = _converse.connection.addHandler( this.handleMUCMessage.bind(this), null, 'message', null, null, room_jid, {'matchBareFromJid': true} ); }, removeHandlers: function () { /* Remove the presence and message handlers that were * registered for this chat room. */ 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; }, join: function (nick, password) { /* Join the chat room. * * Parameters: * (String) nick: The user's nickname * (String) password: Optional password, if required by * the room. */ nick = nick ? nick : this.model.get('nick'); if (!nick) { return this.checkForReservedNick(); } this.registerHandlers(); if (this.model.get('connection_status') === Strophe.Status.CONNECTED) { // We have restored a chat room from session storage, // so we don't send out a presence stanza again. return this; } var stanza = $pres({ 'from': _converse.connection.jid, 'to': this.getRoomJIDAndNick(nick) }).c("x", {'xmlns': Strophe.NS.MUC}) .c("history", {'maxstanzas': _converse.muc_history_max_stanzas}).up(); if (password) { stanza.cnode(Strophe.xmlElement("password", [], password)); } this.model.save('connection_status', Strophe.Status.CONNECTING); _converse.connection.send(stanza); return this; }, cleanup: function () { this.model.save('connection_status', Strophe.Status.DISCONNECTED); this.removeHandlers(); _converse.ChatBoxView.prototype.close.apply(this, arguments); }, leave: function(exit_msg) { /* Leave the chat room. * * Parameters: * (String) exit_msg: Optional message to indicate your * reason for leaving. */ this.hide(); this.occupantsview.model.reset(); this.occupantsview.model.browserStorage._clear(); if (!_converse.connection.connected || this.model.get('connection_status') === Strophe.Status.DISCONNECTED) { // Don't send out a stanza if we're not connected. this.cleanup(); return; } var presence = $pres({ type: "unavailable", from: _converse.connection.jid, to: this.getRoomJIDAndNick() }); if (exit_msg !== null) { presence.c("status", exit_msg); } _converse.connection.sendPresence( presence, this.cleanup.bind(this), this.cleanup.bind(this), 2000 ); }, renderConfigurationForm: function (stanza) { /* Renders a form given an IQ stanza containing the current * room configuration. * * Returns a promise which resolves once the user has * either submitted the form, or canceled it. * * Parameters: * (XMLElement) stanza: The IQ stanza containing the room config. */ var that = this, $body = this.$('.chatroom-body'); $body.children().addClass('hidden'); // Remove any existing forms $body.find('form.chatroom-form').remove(); $body.append(_converse.templates.chatroom_form()); var $form = $body.find('form.chatroom-form'), $fieldset = $form.children('fieldset:first'), $stanza = $(stanza), $fields = $stanza.find('field'), title = $stanza.find('title').text(), instructions = $stanza.find('instructions').text(); $fieldset.find('span.spinner').remove(); $fieldset.append($('').text(title)); if (instructions && instructions !== title) { $fieldset.append($('

').text(instructions)); } _.each($fields, function (field) { $fieldset.append(utils.xForm2webForm($(field), $stanza)); }); $form.append('

'); $fieldset = $form.children('fieldset:last'); $fieldset.append(''); $fieldset.append(''); $fieldset.find('input[type=button]').on('click', function (ev) { ev.preventDefault(); that.cancelConfiguration(); }); $form.on('submit', function (ev) { ev.preventDefault(); that.saveConfiguration(ev.target); }); }, sendConfiguration: function(config, onSuccess, onError) { /* Send an IQ stanza with the room configuration. * * Parameters: * (Array) config: The room configuration * (Function) onSuccess: Callback upon succesful IQ response * The first parameter passed in is IQ containing the * room configuration. * The second is the response IQ from the server. * (Function) onError: Callback upon error IQ response * The first parameter passed in is IQ containing the * room configuration. * The second is the response IQ from the server. */ var iq = $iq({to: this.model.get('jid'), type: "set"}) .c("query", {xmlns: Strophe.NS.MUC_OWNER}) .c("x", {xmlns: Strophe.NS.XFORM, type: "submit"}); _.each(config || [], function (node) { iq.cnode(node).up(); }); onSuccess = _.isUndefined(onSuccess) ? _.noop : _.partial(onSuccess, iq.nodeTree); onError = _.isUndefined(onError) ? _.noop : _.partial(onError, iq.nodeTree); return _converse.connection.sendIQ(iq, onSuccess, onError); }, saveConfiguration: function (form) { /* Submit the room configuration form by sending an IQ * stanza to the server. * * Returns a promise which resolves once the XMPP server * has return a response IQ. * * Parameters: * (HTMLElement) form: The configuration form DOM element. */ var deferred = new $.Deferred(); var that = this; var $inputs = $(form).find(':input:not([type=button]):not([type=submit])'), configArray = []; $inputs.each(function () { configArray.push(utils.webForm2xForm(this)); }); this.sendConfiguration( configArray, deferred.resolve, deferred.reject ); this.$el.find('div.chatroom-form-container').hide( function () { $(this).remove(); that.$el.find('.chat-area').removeClass('hidden'); that.$el.find('.occupants').removeClass('hidden'); }); return deferred.promise(); }, autoConfigureChatRoom: function (stanza) { /* Automatically configure room based on the * 'roomconfigure' data on this view's model. * * Returns a promise which resolves once a response IQ has * been received. * * Parameters: * (XMLElement) stanza: IQ stanza from the server, * containing the configuration. */ var that = this, configArray = [], $fields = $(stanza).find('field'), count = $fields.length, config = this.model.get('roomconfig'); $fields.each(function () { var fieldname = this.getAttribute('var').replace('muc#roomconfig_', ''), type = this.getAttribute('type'), value; if (fieldname in config) { switch (type) { case 'boolean': value = config[fieldname] ? 1 : 0; break; case 'list-multi': // TODO: we don't yet handle "list-multi" types value = this.innerHTML; break; default: value = config[fieldname]; } this.innerHTML = $build('value').t(value); } configArray.push(this); if (!--count) { that.sendConfiguration(configArray); } }); }, cancelConfiguration: function () { /* Remove the configuration form without submitting and * return to the chat view. */ var that = this; this.$el.find('div.chatroom-form-container').hide( function () { $(this).remove(); that.$el.find('.chat-area').removeClass('hidden'); that.$el.find('.occupants').removeClass('hidden'); }); }, fetchRoomConfiguration: function (handler) { /* Send an IQ stanza to fetch the room configuration data. * Returns a promise which resolves once the response IQ * has been received. * * Parameters: * (Function) handler: The handler for the response IQ */ var that = this; var deferred = new $.Deferred(); _converse.connection.sendIQ( $iq({ 'to': this.model.get('jid'), 'type': "get" }).c("query", {xmlns: Strophe.NS.MUC_OWNER}), function (iq) { if (handler) { handler.apply(that, arguments); } deferred.resolve(iq); }, deferred.reject // errback ); return deferred.promise(); }, getRoomFeatures: function () { /* Fetch the room disco info, parse it and then * save it on the Backbone.Model of this chat rooms. */ var deferred = new $.Deferred(); var that = this; _converse.connection.disco.info(this.model.get('jid'), null, function (iq) { /* * See http://xmpp.org/extensions/xep-0045.html#disco-roominfo * * * * * * * * * */ var features = { 'features_fetched': true }; _.each(iq.querySelectorAll('feature'), function (field) { var fieldname = field.getAttribute('var'); if (!fieldname.startsWith('muc_')) { return; } features[fieldname.replace('muc_', '')] = true; }); that.model.save(features); return deferred.resolve(); }, deferred.reject ); return deferred.promise(); }, configureChatRoom: function (ev) { /* Start the process of configuring a chat room, either by * rendering a configuration form, or by auto-configuring * based on the "roomconfig" data stored on the * Backbone.Model. * * Stores the new configuration on the Backbone.Model once * completed. * * Paremeters: * (Event) ev: DOM event that might be passed in if this * method is called due to a user action. In this * case, auto-configure won't happen, regardless of * the settings. */ var that = this; if (_.isUndefined(ev) && this.model.get('auto_configure')) { this.fetchRoomConfiguration().then(that.autoConfigureChatRoom.bind(that)); } else { if (!_.isUndefined(ev) && ev.preventDefault) { ev.preventDefault(); } this.showSpinner(); this.fetchRoomConfiguration().then(that.renderConfigurationForm.bind(that)); } }, submitNickname: function (ev) { /* Get the nickname value from the form and then join the * chat room with it. */ ev.preventDefault(); var $nick = this.$el.find('input[name=nick]'); var nick = $nick.val(); if (!nick) { $nick.addClass('error'); return; } else { $nick.removeClass('error'); } this.$el.find('.chatroom-form-container').replaceWith(''); this.join(nick); }, checkForReservedNick: function () { /* User service-discovery to ask the XMPP server whether * this user has a reserved nickname for this room. * If so, we'll use that, otherwise we render the nickname * form. */ this.showSpinner(); _converse.connection.sendIQ( $iq({ 'to': this.model.get('jid'), 'from': _converse.connection.jid, 'type': "get" }).c("query", { 'xmlns': Strophe.NS.DISCO_INFO, 'node': 'x-roomuser-item' }), this.onNickNameFound.bind(this), this.onNickNameNotFound.bind(this) ); return this; }, onNickNameFound: function (iq) { /* We've received an IQ response from the server which * might contain the user's reserved nickname. * If no nickname is found we either render a form for * them to specify one, or we try to join the room with the * node of the user's JID. * * Parameters: * (XMLElement) iq: The received IQ stanza */ var nick = $(iq) .find('query[node="x-roomuser-item"] identity') .attr('name'); if (!nick) { this.onNickNameNotFound(); } else { this.join(nick); } }, onNickNameNotFound: function (message) { if (_converse.muc_nickname_from_jid) { // We try to enter the room with the node part of // the user's JID. this.join(Strophe.unescapeNode(Strophe.getNodeFromJid(_converse.bare_jid))); } else { this.renderNicknameForm(message); } }, getDefaultNickName: function () { /* The default nickname (used when muc_nickname_from_jid is true) * is the node part of the user's JID. * We put this in a separate method so that it can be * overridden by plugins. */ return Strophe.unescapeNode(Strophe.getNodeFromJid(_converse.bare_jid)); }, onNicknameClash: function (presence) { /* When the nickname is already taken, we either render a * form for the user to choose a new nickname, or we * try to make the nickname unique by adding an integer to * it. So john will become john-2, and then john-3 and so on. * * Which option is take depends on the value of * muc_nickname_from_jid. */ if (_converse.muc_nickname_from_jid) { var nick = presence.getAttribute('from').split('/')[1]; if (nick === this.getDefaultNickName()) { this.join(nick + '-2'); } else { var del= nick.lastIndexOf("-"); var num = nick.substring(del+1, nick.length); this.join(nick.substring(0, del+1) + String(Number(num)+1)); } } else { this.renderNicknameForm( __("The nickname you chose is reserved or currently in use, please choose a different one.") ); } }, renderNicknameForm: function (message) { /* Render a form which allows the user to choose their * nickname. */ this.$('.chatroom-body').children().addClass('hidden'); this.$('span.centered.spinner').remove(); if (!_.isString(message)) { message = ''; } this.$('.chatroom-body').append( _converse.templates.chatroom_nickname_form({ heading: __('Please choose your nickname'), label_nickname: __('Nickname'), label_join: __('Enter room'), validation_message: message })); this.$('.chatroom-form').on('submit', this.submitNickname.bind(this)); }, submitPassword: function (ev) { ev.preventDefault(); var password = this.$el.find('.chatroom-form').find('input[type=password]').val(); this.$el.find('.chatroom-form-container').replaceWith(''); this.join(this.model.get('nick'), password); }, renderPasswordForm: function () { this.$('.chatroom-body').children().addClass('hidden'); this.$('span.centered.spinner').remove(); this.$('.chatroom-body').append( _converse.templates.chatroom_password_form({ heading: __('This chatroom requires a password'), label_password: __('Password: '), label_submit: __('Submit') })); this.$('.chatroom-form').on('submit', this.submitPassword.bind(this)); }, showDisconnectMessage: function (msg) { this.$('.chat-area').addClass('hidden'); this.$('.occupants').addClass('hidden'); this.$('span.centered.spinner').remove(); this.$('.chatroom-body').append($('

'+msg+'

')); }, getMessageFromStatus: function (stat, stanza, is_self) { /* Parameters: * (XMLElement) stat: A element. * (Boolean) is_self: Whether the element refers to the * current user. * (XMLElement) stanza: The original stanza received. */ var code = stat.getAttribute('code'), from_nick; if (is_self && code === "210") { from_nick = Strophe.unescapeNode(Strophe.getResourceFromJid(stanza.getAttribute('from'))); return __(_converse.muc.new_nickname_messages[code], from_nick); } else if (is_self && code === "303") { return __( _converse.muc.new_nickname_messages[code], stanza.querySelector('x item').getAttribute('nick') ); } else if (!is_self && (code in _converse.muc.action_info_messages)) { from_nick = Strophe.unescapeNode(Strophe.getResourceFromJid(stanza.getAttribute('from'))); return __(_converse.muc.action_info_messages[code], from_nick); } else if (code in _converse.muc.info_messages) { return _converse.muc.info_messages[code]; } else if (code !== '110') { if (stat.textContent) { // Sometimes the status contains human readable text and not a code. return stat.textContent; } } return; }, saveAffiliationAndRole: function (pres) { /* Parse the presence stanza for the current user's * affiliation. * * Parameters: * (XMLElement) pres: A stanza. */ // XXX: For some inexplicable reason, the following line of // code works in tests, but not with live data, even though // the passed in stanza looks exactly the same to me: // var item = pres.querySelector('x[xmlns="'+Strophe.NS.MUC_USER+'"] item'); // If we want to eventually get rid of jQuery altogether, // then the Sizzle selector library might still be needed // here. var item = $(pres).find('x[xmlns="'+Strophe.NS.MUC_USER+'"] item').get(0); if (_.isUndefined(item)) { return; } var jid = item.getAttribute('jid'); if (Strophe.getBareJidFromJid(jid) === _converse.bare_jid) { var affiliation = item.getAttribute('affiliation'); var role = item.getAttribute('role'); if (affiliation) { this.model.save({'affiliation': affiliation}); } if (role) { this.model.save({'role': role}); } } }, parseXUserElement: function (x, stanza, is_self) { /* Parse the passed-in * element and construct a map containing relevant * information. */ // 1. Get notification messages based on the elements. var statuses = x.querySelectorAll('status'); var mapper = _.partial(this.getMessageFromStatus, _, stanza, is_self); var notification = { 'messages': _.reject(_.map(statuses, mapper), _.isUndefined), }; // 2. Get disconnection messages based on the elements var codes = _.invokeMap(statuses, Element.prototype.getAttribute, 'code'); var disconnection_codes = _.intersection(codes, _.keys(_converse.muc.disconnect_messages)); var disconnected = is_self && disconnection_codes.length > 0; if (disconnected) { notification.disconnected = true; notification.disconnection_message = _converse.muc.disconnect_messages[disconnection_codes[0]]; } // 3. Find the reason and actor from the element var item = x.querySelector('item'); // By using querySelector above, we assume here there is // one per // element. This appears to be a safe assumption, since // each element pertains to a single user. if (!_.isNull(item)) { var reason = item.querySelector('reason'); if (reason) { notification.reason = reason ? reason.textContent : undefined; } var actor = item.querySelector('actor'); if (actor) { notification.actor = actor ? actor.getAttribute('nick') : undefined; } } return notification; }, displayNotificationsforUser: function (notification) { /* Given the notification object generated by * parseXUserElement, display any relevant messages and * information to the user. */ var that = this; if (notification.disconnected) { this.showDisconnectMessage(notification.disconnection_message); if (notification.actor) { this.showDisconnectMessage(__(___('This action was done by %1$s.'), notification.actor)); } if (notification.reason) { this.showDisconnectMessage(__(___('The reason given is: "%1$s".'), notification.reason)); } this.model.save('connection_status', Strophe.Status.DISCONNECTED); return; } _.each(notification.messages, function (message) { that.$content.append(_converse.templates.info({'message': message})); }); if (notification.reason) { this.showStatusNotification(__('The reason given is: "'+notification.reason+'"'), true); } if (notification.messages.length) { this.scrollDown(); } }, showStatusMessages: function (stanza) { /* Check for status codes and communicate their purpose to the user. * See: http://xmpp.org/registrar/mucstatus.html * * Parameters: * (XMLElement) stanza: The message or presence stanza * containing the status codes. */ var is_self = stanza.querySelectorAll("status[code='110']").length; // Unfortunately this doesn't work (returns empty list) // var elements = stanza.querySelectorAll('x[xmlns="'+Strophe.NS.MUC_USER+'"]'); var elements = _.filter( stanza.querySelectorAll('x'), function (x) { return x.getAttribute('xmlns') === Strophe.NS.MUC_USER; } ); var notifications = _.map( elements, _.partial(this.parseXUserElement.bind(this), _, stanza, is_self) ); _.each(notifications, this.displayNotificationsforUser.bind(this)); return stanza; }, showErrorMessage: function (presence) { // We didn't enter the room, so we must remove it from the MUC // add-on var $error = $(presence).find('error'); if ($error.attr('type') === 'auth') { if ($error.find('not-authorized').length) { this.renderPasswordForm(); } else if ($error.find('registration-required').length) { this.showDisconnectMessage(__('You are not on the member list of this room')); } else if ($error.find('forbidden').length) { this.showDisconnectMessage(__('You have been banned from this room')); } } else if ($error.attr('type') === 'modify') { if ($error.find('jid-malformed').length) { this.showDisconnectMessage(__('No nickname was specified')); } } else if ($error.attr('type') === 'cancel') { if ($error.find('not-allowed').length) { this.showDisconnectMessage(__('You are not allowed to create new rooms')); } else if ($error.find('not-acceptable').length) { this.showDisconnectMessage(__("Your nickname doesn't conform to this room's policies")); } else if ($error.find('conflict').length) { this.onNicknameClash(presence); } else if ($error.find('item-not-found').length) { this.showDisconnectMessage(__("This room does not (yet) exist")); } else if ($error.find('service-unavailable').length) { this.showDisconnectMessage(__("This room has reached its maximum number of occupants")); } } }, showSpinner: function () { this.$('.chatroom-body').children().addClass('hidden'); this.$el.find('.chatroom-body').prepend(''); }, hideSpinner: function () { /* Check if the spinner is being shown and if so, hide it. * Also make sure then that the chat area and occupants * list are both visible. */ var that = this; var $spinner = this.$el.find('.spinner'); if ($spinner.length) { $spinner.hide(function () { $(this).remove(); that.$el.find('.chat-area').removeClass('hidden'); that.$el.find('.occupants').removeClass('hidden'); that.scrollDown(); }); } return this; }, createInstantRoom: function () { /* Sends an empty IQ config stanza to inform the server that the * room should be created with its default configuration. * * See http://xmpp.org/extensions/xep-0045.html#createroom-instant */ this.saveConfiguration().then(this.getRoomFeatures.bind(this)); }, onChatRoomPresence: function (pres) { /* Handles all MUC presence stanzas. * * Parameters: * (XMLElement) pres: The stanza */ if (pres.getAttribute('type') === 'error') { this.model.save('connection_status', Strophe.Status.DISCONNECTED); this.showErrorMessage(pres); return true; } var show_status_messages = true; var is_self = pres.querySelector("status[code='110']"); var new_room = pres.querySelector("status[code='201']"); if (is_self) { this.saveAffiliationAndRole(pres); } if (is_self && new_room) { // This is a new room. It will now be configured // and the configuration cached on the // Backbone.Model. if (_converse.muc_instant_rooms) { this.createInstantRoom(); // Accept default configuration } else { this.configureChatRoom(); if (!this.model.get('auto_configure')) { // We don't show status messages if the // configuration form is being shown. show_status_messages = false; } } } else if (!this.model.get('features_fetched') && this.model.get('connection_status') !== Strophe.Status.CONNECTED) { // The features for this room weren't fetched yet, perhaps // because it's a new room without locking (in which // case Prosody doesn't send a 201 status). // This is the first presence received for the room, so // a good time to fetch the features. this.getRoomFeatures(); } if (show_status_messages) { this.hideSpinner().showStatusMessages(pres); } this.occupantsview.updateOccupantsOnPresence(pres); if (this.model.get('role') !== 'none') { this.model.save('connection_status', Strophe.Status.CONNECTED); } return true; }, setChatRoomSubject: function (sender, subject) { this.$el.find('.chatroom-topic').text(subject).attr('title', subject); // For translators: the %1$s and %2$s parts will get replaced by the user and topic text respectively // Example: Topic set by JC Brand to: Hello World! this.$content.append( _converse.templates.info({ 'message': __('Topic set by %1$s to: %2$s', sender, subject) })); this.scrollDown(); }, onChatRoomMessage: function (message) { /* Given a stanza, create a message * Backbone.Model if appropriate. * * Parameters: * (XMLElement) msg: The received message stanza */ var original_stanza = message, forwarded = message.querySelector('forwarded'), delay; if (!_.isNull(forwarded)) { message = forwarded.querySelector('message'); delay = forwarded.querySelector('delay'); } var jid = message.getAttribute('from'), msgid = message.getAttribute('id'), resource = Strophe.getResourceFromJid(jid), sender = resource && Strophe.unescapeNode(resource) || '', subject = _.propertyOf(message.querySelector('subject'))('textContent'), dupes = msgid && this.model.messages.filter(function (msg) { // Find duplicates. // Some bots (like HAL in the prosody chatroom) // respond to commands with the same ID as the // original message. So we also check the sender. return msg.get('msgid') === msgid && msg.get('fullname') === sender; }); if (dupes && dupes.length) { return true; } if (subject) { this.setChatRoomSubject(sender, subject); } if (sender === '') { return true; } this.model.createMessage(message, delay, original_stanza); if (sender !== this.model.get('nick')) { // We only emit an event if it's not our own message _converse.emit('message', message); } return true; }, fetchArchivedMessages: function (options) { /* Fetch archived chat messages from the XMPP server. * * Then, upon receiving them, call onChatRoomMessage * so that they are displayed inside it. */ var that = this; if (!_converse.features.findWhere({'var': Strophe.NS.MAM})) { _converse.log("Attempted to fetch archived messages but this user's server doesn't support XEP-0313"); return; } this.addSpinner(); _converse.api.archive.query(_.extend(options, {'groupchat': true}), function (messages) { that.clearSpinner(); if (messages.length) { _.each(messages, that.onChatRoomMessage.bind(that)); } }, function () { that.clearSpinner(); _converse.log("Error while trying to fetch archived messages", "error"); } ); } }); _converse.ChatRoomOccupant = Backbone.Model.extend({ initialize: function (attributes) { this.set(_.extend({ 'id': _converse.connection.getUniqueId(), }, attributes)); } }); _converse.ChatRoomOccupantView = Backbone.View.extend({ tagName: 'li', initialize: function () { this.model.on('change', this.render, this); this.model.on('destroy', this.destroy, this); }, render: function () { var new_el = _converse.templates.occupant( _.extend( this.model.toJSON(), { 'hint_occupant': __('Click to mention this user in your message.'), 'desc_moderator': __('This user is a moderator.'), 'desc_occupant': __('This user can send messages in this room.'), 'desc_visitor': __('This user can NOT send messages in this room.') }) ); var $parents = this.$el.parents(); if ($parents.length) { this.$el.replaceWith(new_el); this.setElement($parents.first().children('#'+this.model.get('id')), true); this.delegateEvents(); } else { this.$el.replaceWith(new_el); this.setElement(new_el, true); } return this; }, destroy: function () { this.$el.remove(); } }); _converse.ChatRoomOccupants = Backbone.Collection.extend({ model: _converse.ChatRoomOccupant }); _converse.ChatRoomOccupantsView = Backbone.Overview.extend({ tagName: 'div', className: 'occupants', initialize: function () { this.model.on("add", this.onOccupantAdded, this); }, render: function () { this.$el.html( _converse.templates.chatroom_sidebar({ 'allow_muc_invitations': _converse.allow_muc_invitations, 'label_invitation': __('Invite'), 'label_occupants': __('Occupants') }) ); if (_converse.allow_muc_invitations) { _converse.api.waitUntil('rosterContactsFetched').then(this.initInviteWidget.bind(this)); } return this; }, onOccupantAdded: function (item) { var view = this.get(item.get('id')); if (!view) { view = this.add(item.get('id'), new _converse.ChatRoomOccupantView({model: item})); } else { delete view.model; // Remove ref to old model to help garbage collection view.model = item; view.initialize(); } this.$('.occupant-list').append(view.render().$el); }, parsePresence: function (pres) { var id = Strophe.getResourceFromJid(pres.getAttribute("from")); var data = { nick: id, type: pres.getAttribute("type"), states: [] }; _.each(pres.childNodes, function (child) { switch (child.nodeName) { case "status": data.status = child.textContent || null; break; case "show": data.show = child.textContent || null; break; case "x": if (child.getAttribute("xmlns") === Strophe.NS.MUC_USER) { _.each(child.childNodes, function (item) { switch (item.nodeName) { case "item": data.affiliation = item.getAttribute("affiliation"); data.role = item.getAttribute("role"); data.jid = item.getAttribute("jid"); data.nick = item.getAttribute("nick") || data.nick; break; case "status": if (item.getAttribute("code")) { data.states.push(item.getAttribute("code")); } } }); } } }); return data; }, findOccupant: function (data) { /* Try to find an existing occupant based on the passed in * data object. * * If we have a JID, we use that as lookup variable, * otherwise we use the nick. We don't always have both, * but should have at least one or the other. */ var jid = Strophe.getBareJidFromJid(data.jid); if (jid !== null) { return this.model.where({'jid': jid}).pop(); } else { return this.model.where({'nick': data.nick}).pop(); } }, updateOccupantsOnPresence: function (pres) { /* Given a presence stanza, update the occupant models * based on its contents. * * Parameters: * (XMLElement) pres: The presence stanza */ var data = this.parsePresence(pres); if (data.type === 'error') { return true; } var occupant = this.findOccupant(data); switch (data.type) { case 'unavailable': if (occupant) { occupant.destroy(); } break; default: var jid = Strophe.getBareJidFromJid(data.jid); var attributes = _.extend(data, { 'jid': jid ? jid : undefined, 'resource': data.jid ? Strophe.getResourceFromJid(data.jid) : undefined }); if (occupant) { occupant.save(attributes); } else { this.model.create(attributes); } } }, promptForInvite: function (suggestion) { var reason = prompt( __(___('You are about to invite %1$s to the chat room "%2$s". '), suggestion.text.label, this.model.get('id')) + __("You may optionally include a message, explaining the reason for the invitation.") ); if (reason !== null) { this.chatroomview.directInvite(suggestion.text.value, reason); } suggestion.target.value = ''; }, inviteFormSubmitted: function (evt) { evt.preventDefault(); var el = evt.target.querySelector('input.invited-contact'); this.promptForInvite({ 'target': el, 'text': { 'label': el.value, 'value': el.value }}); }, initInviteWidget: function () { var form = this.el.querySelector('form.room-invite'); form.addEventListener('submit', this.inviteFormSubmitted.bind(this)); var el = this.el.querySelector('input.invited-contact'); var list = _converse.roster.map(function (item) { var label = item.get('fullname') || item.get('jid'); return {'label': label, 'value':item.get('jid')}; }); var awesomplete = new Awesomplete(el, { 'minChars': 1, 'list': list }); el.addEventListener('awesomplete-selectcomplete', this.promptForInvite.bind(this)); return this; } }); _converse.RoomsPanel = Backbone.View.extend({ /* Backbone View which renders the "Rooms" tab and accompanying * panel in the control box. * * In this panel, chat rooms can be listed, joined and new rooms * can be created. */ tagName: 'div', className: 'controlbox-pane', id: 'chatrooms', events: { 'submit form.add-chatroom': 'createChatRoom', 'click input#show-rooms': 'showRooms', 'click a.open-room': 'createChatRoom', 'click a.room-info': 'toggleRoomInfo', 'change input[name=server]': 'setDomain', 'change input[name=nick]': 'setNick' }, initialize: function (cfg) { this.$parent = cfg.$parent; this.model.on('change:muc_domain', this.onDomainChange, this); this.model.on('change:nick', this.onNickChange, this); }, render: function () { this.$parent.append( this.$el.html( _converse.templates.room_panel({ 'server_input_type': _converse.hide_muc_server && 'hidden' || 'text', 'server_label_global_attr': _converse.hide_muc_server && ' hidden' || '', 'label_room_name': __('Room name'), 'label_nickname': __('Nickname'), 'label_server': __('Server'), 'label_join': __('Join Room'), 'label_show_rooms': __('Show rooms') }) )); this.$tabs = this.$parent.parent().find('#controlbox-tabs'); var controlbox = _converse.chatboxes.get('controlbox'); this.$tabs.append(_converse.templates.chatrooms_tab({ 'label_rooms': __('Rooms'), 'is_current': controlbox.get('active-panel') === ROOMS_PANEL_ID })); if (controlbox.get('active-panel') !== ROOMS_PANEL_ID) { this.$el.addClass('hidden'); } return this; }, onDomainChange: function (model) { var $server = this.$el.find('input.new-chatroom-server'); $server.val(model.get('muc_domain')); if (_converse.auto_list_rooms) { this.updateRoomsList(); } }, onNickChange: function (model) { var $nick = this.$el.find('input.new-chatroom-nick'); $nick.val(model.get('nick')); }, informNoRoomsFound: function () { var $available_chatrooms = this.$el.find('#available-chatrooms'); // For translators: %1$s is a variable and will be replaced with the XMPP server name $available_chatrooms.html('
'+__('No rooms on %1$s',this.model.get('muc_domain'))+'
'); $('input#show-rooms').show().siblings('span.spinner').remove(); }, onRoomsFound: function (iq) { /* Handle the IQ stanza returned from the server, containing * all its public rooms. */ var name, jid, i, fragment, $available_chatrooms = this.$el.find('#available-chatrooms'); this.rooms = $(iq).find('query').find('item'); if (this.rooms.length) { // For translators: %1$s is a variable and will be // replaced with the XMPP server name $available_chatrooms.html('
'+__('Rooms on %1$s',this.model.get('muc_domain'))+'
'); fragment = document.createDocumentFragment(); for (i=0; i'); this.model.save({muc_domain: server}); this.updateRoomsList(); }, insertRoomInfo: function (el, stanza) { /* Insert room info (based on returned #disco IQ stanza) * * Parameters: * (HTMLElement) el: The HTML DOM element that should * contain the info. * (XMLElement) stanza: The IQ stanza containing the room * info. */ var $stanza = $(stanza); // All MUC features found here: http://xmpp.org/registrar/disco-features.html $(el).find('span.spinner').replaceWith( _converse.templates.room_description({ 'desc': $stanza.find('field[var="muc#roominfo_description"] value').text(), 'occ': $stanza.find('field[var="muc#roominfo_occupants"] value').text(), 'hidden': $stanza.find('feature[var="muc_hidden"]').length, 'membersonly': $stanza.find('feature[var="muc_membersonly"]').length, 'moderated': $stanza.find('feature[var="muc_moderated"]').length, 'nonanonymous': $stanza.find('feature[var="muc_nonanonymous"]').length, 'open': $stanza.find('feature[var="muc_open"]').length, 'passwordprotected': $stanza.find('feature[var="muc_passwordprotected"]').length, 'persistent': $stanza.find('feature[var="muc_persistent"]').length, 'publicroom': $stanza.find('feature[var="muc_public"]').length, 'semianonymous': $stanza.find('feature[var="muc_semianonymous"]').length, 'temporary': $stanza.find('feature[var="muc_temporary"]').length, 'unmoderated': $stanza.find('feature[var="muc_unmoderated"]').length, 'label_desc': __('Description:'), 'label_occ': __('Occupants:'), 'label_features': __('Features:'), 'label_requires_auth': __('Requires authentication'), 'label_hidden': __('Hidden'), 'label_requires_invite': __('Requires an invitation'), 'label_moderated': __('Moderated'), 'label_non_anon': __('Non-anonymous'), 'label_open_room': __('Open room'), 'label_permanent_room': __('Permanent room'), 'label_public': __('Public'), 'label_semi_anon': __('Semi-anonymous'), 'label_temp_room': __('Temporary room'), 'label_unmoderated': __('Unmoderated') }) ); }, toggleRoomInfo: function (ev) { /* Show/hide extra information about a room in the listing. */ var target = ev.target, $parent = $(target).parent('dd'), $div = $parent.find('div.room-info'); if ($div.length) { $div.remove(); } else { $parent.find('span.spinner').remove(); $parent.append(''); _converse.connection.disco.info( $(target).attr('data-room-jid'), null, _.partial(this.insertRoomInfo, $parent[0]) ); } }, createChatRoom: function (ev) { ev.preventDefault(); var name, $name, server, $server, jid; if (ev.type === 'click') { name = $(ev.target).text(); jid = $(ev.target).attr('data-room-jid'); } else { $name = this.$el.find('input.new-chatroom-name'); $server= this.$el.find('input.new-chatroom-server'); server = $server.val(); name = $name.val().trim(); $name.val(''); // Clear the input if (name && server) { jid = Strophe.escapeNode(name.toLowerCase()) + '@' + server.toLowerCase(); $name.removeClass('error'); $server.removeClass('error'); this.model.save({muc_domain: server}); } else { if (!name) { $name.addClass('error'); } if (!server) { $server.addClass('error'); } return; } } _converse.createChatRoom({ 'id': jid, 'jid': jid, 'name': name || Strophe.unescapeNode(Strophe.getNodeFromJid(jid)), 'type': 'chatroom', 'box_id': b64_sha1(jid) }); }, setDomain: function (ev) { this.model.save({muc_domain: ev.target.value}); }, setNick: function (ev) { this.model.save({nick: ev.target.value}); } }); /************************ End of ChatRoomView **********************/ _converse.onDirectMUCInvitation = function (message) { /* A direct MUC invitation to join a room has been received * See XEP-0249: Direct MUC invitations. * * Parameters: * (XMLElement) message: The message stanza containing the * invitation. */ var $message = $(message), $x = $message.children('x[xmlns="jabber:x:conference"]'), from = Strophe.getBareJidFromJid($message.attr('from')), room_jid = $x.attr('jid'), reason = $x.attr('reason'), contact = _converse.roster.get(from), result; if (_converse.auto_join_on_invite) { result = true; } else { // Invite request might come from someone not your roster list contact = contact? contact.get('fullname'): Strophe.getNodeFromJid(from); if (!reason) { result = confirm( __(___("%1$s has invited you to join a chat room: %2$s"), contact, room_jid) ); } else { result = confirm( __(___('%1$s has invited you to join a chat room: %2$s, and left the following reason: "%3$s"'), contact, room_jid, reason) ); } } if (result === true) { var chatroom = _converse.createChatRoom({ 'id': room_jid, 'jid': room_jid, 'name': Strophe.unescapeNode(Strophe.getNodeFromJid(room_jid)), 'nick': Strophe.unescapeNode(Strophe.getNodeFromJid(_converse.connection.jid)), 'type': 'chatroom', 'box_id': b64_sha1(room_jid), 'password': $x.attr('password') }); if (!_.includes( [Strophe.Status.CONNECTING, Strophe.Status.CONNECTED], chatroom.get('connection_status')) ) { _converse.chatboxviews.get(room_jid).join(); } } }; if (_converse.allow_muc_invitations) { var registerDirectInvitationHandler = function () { _converse.connection.addHandler( function (message) { _converse.onDirectMUCInvitation(message); return true; }, 'jabber:x:conference', 'message'); }; _converse.on('connected', registerDirectInvitationHandler); _converse.on('reconnected', registerDirectInvitationHandler); } var autoJoinRooms = function () { /* Automatically join chat rooms, based on the * "auto_join_rooms" configuration setting, which is an array * of strings (room JIDs) or objects (with room JID and other * settings). */ _.each(_converse.auto_join_rooms, function (room) { if (_.isString(room)) { _converse.api.rooms.open(room); } else if (_.isObject(room)) { _converse.api.rooms.open(room.jid, room.nick); } else { _converse.log('Invalid room criteria specified for "auto_join_rooms"', 'error'); } }); }; _converse.on('chatBoxesFetched', autoJoinRooms); _converse.getChatRoom = function (jid, attrs, fetcher) { jid = jid.toLowerCase(); return _converse.getViewForChatBox(fetcher(_.extend({ 'id': jid, 'jid': jid, 'name': Strophe.unescapeNode(Strophe.getNodeFromJid(jid)), 'type': 'chatroom', 'box_id': b64_sha1(jid) }, attrs))); }; /* We extend the default converse.js API to add methods specific to MUC * chat rooms. */ _.extend(_converse.api, { 'rooms': { 'close': function (jids) { if (_.isUndefined(jids)) { _converse.chatboxviews.each(function (view) { if (view.is_chatroom && view.model) { view.close(); } }); } else if (_.isString(jids)) { var view = _converse.chatboxviews.get(jids); if (view) { view.close(); } } else { _.each(jids, function (jid) { var view = _converse.chatboxviews.get(jid); if (view) { view.close(); } }); } }, 'open': function (jids, attrs) { if (_.isString(attrs)) { attrs = {'nick': attrs}; } else if (_.isUndefined(attrs)) { attrs = {}; } if (_.isUndefined(attrs.maximize)) { attrs.maximize = false; } if (!attrs.nick && _converse.muc_nickname_from_jid) { attrs.nick = Strophe.getNodeFromJid(_converse.bare_jid); } if (_.isUndefined(jids)) { throw new TypeError('rooms.open: You need to provide at least one JID'); } else if (_.isString(jids)) { return _converse.getChatRoom(jids, attrs, _converse.createChatRoom); } return _.map(jids, _.partial(_converse.getChatRoom, _, attrs, _converse.createChatRoom)); }, 'get': function (jids, attrs, create) { if (_.isString(attrs)) { attrs = {'nick': attrs}; } else if (_.isUndefined(attrs)) { attrs = {}; } if (_.isUndefined(jids)) { var result = []; _converse.chatboxes.each(function (chatbox) { if (chatbox.get('type') === 'chatroom') { result.push(_converse.getViewForChatBox(chatbox)); } }); return result; } var fetcher = _.partial(_converse.chatboxviews.getChatBox.bind(_converse.chatboxviews), _, create); if (!attrs.nick) { attrs.nick = Strophe.getNodeFromJid(_converse.bare_jid); } if (_.isString(jids)) { return _converse.getChatRoom(jids, attrs, fetcher); } return _.map(jids, _.partial(_converse.getChatRoom, _, attrs, fetcher)); } } }); var reconnectToChatRooms = function () { /* Upon a reconnection event from converse, join again * all the open chat rooms. */ _converse.chatboxviews.each(function (view) { if (view.model.get('type') === 'chatroom') { view.model.save('connection_status', Strophe.Status.DISCONNECTED); view.join(); } }); }; _converse.on('reconnected', reconnectToChatRooms); var disconnectChatRooms = function () { /* When disconnecting, or reconnecting, mark all chat rooms as * disconnected, so that they will be properly entered again * when fetched from session storage. */ _converse.chatboxes.each(function (model) { if (model.get('type') === 'chatroom') { model.save('connection_status', Strophe.Status.DISCONNECTED); } }); }; _converse.on('reconnecting', disconnectChatRooms); _converse.on('disconnecting', disconnectChatRooms); } }); }));