// Converse.js (A browser based XMPP chat client) // http://conversejs.org // // Copyright (c) 2012-2016, Jan-Carel Brand // Licensed under the Mozilla Public License (MPLv2) // /*global Backbone, define, window, document, locales */ (function (root, factory) { // Two modules are loaded as dependencies. // // * **converse-dependencies**: A list of dependencies converse.js depends on. // The path to this module is in main.js and the module itself can be overridden. // * **converse-templates**: The HTML templates used by converse.js. // // The dependencies are then split up and passed into the factory function, // which contains and instantiates converse.js. define("converse-core", [ "jquery", "underscore", "polyfill", "utils", "moment_with_locales", "strophe", "converse-templates", "strophe.disco", "strophe.rsm", "strophe.vcard", "backbone.browserStorage", "backbone.overview", "typeahead", ], factory); }(this, function ($, _, dummy, utils, moment, Strophe, templates) { /* * Cannot use this due to Safari bug. * See https://github.com/jcbrand/converse.js/issues/196 */ // "use strict"; // Strophe globals var $build = Strophe.$build; var $iq = Strophe.$iq; var $msg = Strophe.$msg; var $pres = Strophe.$pres; var b64_sha1 = Strophe.SHA1.b64_sha1; Strophe = Strophe.Strophe; // Use Mustache style syntax for variable interpolation /* Configuration of underscore templates (this config is distinct to the * config of requirejs-tpl in main.js). This one is for normal inline templates. */ _.templateSettings = { evaluate : /\{\[([\s\S]+?)\]\}/g, interpolate : /\{\{([\s\S]+?)\}\}/g }; // We create an object to act as the "this" context for event handlers (as // defined below and accessible via converse_api.listen). // We don't want the inner converse object to be the context, since it // contains sensitive information, and we don't want it to be something in // the DOM or window, because then anyone can trigger converse events. var event_context = {}; var converse = { plugins: {}, initialized_plugins: [], templates: templates, emit: function (evt, data) { $(event_context).trigger(evt, data); }, once: function (evt, handler) { $(event_context).one(evt, handler); }, on: function (evt, handler) { $(event_context).bind(evt, handler); }, off: function (evt, handler) { $(event_context).unbind(evt, handler); } }; // Module-level constants converse.STATUS_WEIGHTS = { 'offline': 6, 'unavailable': 5, 'xa': 4, 'away': 3, 'dnd': 2, 'chat': 1, // We currently don't differentiate between "chat" and "online" 'online': 1 }; converse.LOGIN = "login"; converse.ANONYMOUS = "anonymous"; converse.PREBIND = "prebind"; converse.OPENED = 'opened'; converse.CLOSED = 'closed'; var KEY = { ENTER: 13, FORWARD_SLASH: 47 }; var PRETTY_CONNECTION_STATUS = { 0: 'ERROR', 1: 'CONNECTING', 2: 'CONNFAIL', 3: 'AUTHENTICATING', 4: 'AUTHFAIL', 5: 'CONNECTED', 6: 'DISCONNECTED', 7: 'DISCONNECTING', 8: 'ATTACHED', 9: 'REDIRECT' }; // TODO Refactor into external MAM plugin // XEP-0059 Result Set Management var RSM_ATTRIBUTES = ['max', 'first', 'last', 'after', 'before', 'index', 'count']; // XEP-0313 Message Archive Management var MAM_ATTRIBUTES = ['with', 'start', 'end']; converse.queryForArchivedMessages = function (options, callback, errback) { /* Do a MAM (XEP-0313) query for archived messages. * * Parameters: * (Object) options - Query parameters, either MAM-specific or also for Result Set Management. * (Function) callback - A function to call whenever we receive query-relevant stanza. * (Function) errback - A function to call when an error stanza is received. * * The options parameter can also be an instance of * Strophe.RSM to enable easy querying between results pages. * * The callback function may be called multiple times, first * for the initial IQ result and then for each message * returned. The last time the callback is called, a * Strophe.RSM object is returned on which "next" or "previous" * can be called before passing it in again to this method, to * get the next or previous page in the result set. */ var date, messages = []; if (typeof options === "function") { callback = options; errback = callback; } if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { converse.log('This server does not support XEP-0313, Message Archive Management'); errback(null); return; } var queryid = converse.connection.getUniqueId(); var attrs = {'type':'set'}; if (typeof options !== "undefined" && options.groupchat) { if (!options['with']) { throw new Error('You need to specify a "with" value containing the chat room JID, when querying groupchat messages.'); } attrs.to = options['with']; } var stanza = $iq(attrs).c('query', {'xmlns':Strophe.NS.MAM, 'queryid':queryid}); if (typeof options !== "undefined") { stanza.c('x', {'xmlns':Strophe.NS.XFORM, 'type': 'submit'}) .c('field', {'var':'FORM_TYPE', 'type': 'hidden'}) .c('value').t(Strophe.NS.MAM).up().up(); if (options['with'] && !options.groupchat) { stanza.c('field', {'var':'with'}).c('value').t(options['with']).up().up(); } _.each(['start', 'end'], function (t) { if (options[t]) { date = moment(options[t]); if (date.isValid()) { stanza.c('field', {'var':t}).c('value').t(date.format()).up().up(); } else { throw new TypeError('archive.query: invalid date provided for: '+t); } } }); stanza.up(); if (options instanceof Strophe.RSM) { stanza.cnode(options.toXML()); } else if (_.intersection(RSM_ATTRIBUTES, _.keys(options)).length) { stanza.cnode(new Strophe.RSM(options).toXML()); } } converse.connection.addHandler(function (message) { var $msg = $(message), $fin, rsm; if (typeof callback === "function") { $fin = $msg.find('fin[xmlns="'+Strophe.NS.MAM+'"]'); if ($fin.length) { rsm = new Strophe.RSM({xml: $fin.find('set')[0]}); _.extend(rsm, _.pick(options, ['max'])); _.extend(rsm, _.pick(options, MAM_ATTRIBUTES)); callback(messages, rsm); return false; // We've received all messages, decommission this handler } else if (queryid === $msg.find('result').attr('queryid')) { messages.push(message); } return true; } else { return false; // There's no callback, so no use in continuing this handler. } }, Strophe.NS.MAM); converse.connection.sendIQ(stanza, null, errback, converse.message_archiving_timeout); }; converse.log = function (txt, level) { var logger; if (typeof console === "undefined" || typeof console.log === "undefined") { logger = { log: function () {}, error: function () {} }; } else { logger = console; } if (converse.debug) { if (level === 'error') { logger.log('ERROR: '+txt); } else { logger.log(txt); } } }; converse.initialize = function (settings, callback) { "use strict"; var converse = this; var unloadevent; if ('onpagehide' in window) { // Pagehide gets thrown in more cases than unload. Specifically it // gets thrown when the page is cached and not just // closed/destroyed. It's the only viable event on mobile Safari. // https://www.webkit.org/blog/516/webkit-page-cache-ii-the-unload-event/ unloadevent = 'pagehide'; } else if ('onbeforeunload' in window) { unloadevent = 'beforeunload'; } else if ('onunload' in window) { unloadevent = 'unload'; } // Logging Strophe.log = function (level, msg) { converse.log(level+' '+msg, level); }; Strophe.error = function (msg) { converse.log(msg, 'error'); }; // Add Strophe Namespaces Strophe.addNamespace('CARBONS', 'urn:xmpp:carbons:2'); Strophe.addNamespace('CHATSTATES', 'http://jabber.org/protocol/chatstates'); Strophe.addNamespace('CSI', 'urn:xmpp:csi:0'); Strophe.addNamespace('MAM', 'urn:xmpp:mam:0'); Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx'); Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm'); Strophe.addNamespace('XFORM', 'jabber:x:data'); // Instance level constants this.TIMEOUTS = { // Set as module attr so that we can override in tests. 'PAUSED': 20000, 'INACTIVE': 90000 }; // XEP-0085 Chat states // http://xmpp.org/extensions/xep-0085.html this.INACTIVE = 'inactive'; this.ACTIVE = 'active'; this.COMPOSING = 'composing'; this.PAUSED = 'paused'; this.GONE = 'gone'; // Detect support for the user's locale // ------------------------------------ this.isConverseLocale = function (locale) { return typeof locales[locale] !== "undefined"; }; this.isMomentLocale = function (locale) { return moment.locale() !== moment.locale(locale); }; this.user_settings = settings; // Save the user settings so that they can be used by plugins this.wrappedChatBox = function (chatbox) { /* Wrap a chatbox for outside consumption (i.e. so that it can be * returned via the API. */ if (!chatbox) { return; } var view = converse.chatboxviews.get(chatbox.get('jid')); return { 'close': view.close.bind(view), 'focus': view.focus.bind(view), 'get': chatbox.get.bind(chatbox), // FIXME: leaky abstraction from MUC 'is_chatroom': view.is_chatroom, 'maximize': chatbox.maximize.bind(chatbox), 'minimize': chatbox.minimize.bind(chatbox), 'open': view.show.bind(view), 'set': chatbox.set.bind(chatbox) }; }; this.isLocaleAvailable = function (locale, available) { /* Check whether the locale or sub locale (e.g. en-US, en) is supported. * * Parameters: * (Function) available - returns a boolean indicating whether the locale is supported */ if (available(locale)) { return locale; } else { var sublocale = locale.split("-")[0]; if (sublocale !== locale && available(sublocale)) { return sublocale; } } }; this.detectLocale = function (library_check) { /* Determine which locale is supported by the user's system as well * as by the relevant library (e.g. converse.js or moment.js). * * Parameters: * (Function) library_check - returns a boolean indicating whether the locale is supported */ var locale, i; if (window.navigator.userLanguage) { locale = this.isLocaleAvailable(window.navigator.userLanguage, library_check); } if (window.navigator.languages && !locale) { for (i=0; i 0) { this.idle_seconds = 0; } if (!converse.connection.authenticated) { // We can't send out any stanzas when there's no authenticated connection. // This can happen when the connection reconnects. return; } if (this.inactive) { this.sendCSI(converse.ACTIVE); } if (this.auto_changed_status === true) { this.auto_changed_status = false; this.xmppstatus.setStatus('online'); } }; this.onEverySecond = function () { /* An interval handler running every second. * Used for CSI and the auto_away and auto_xa * features. */ if (!converse.connection.authenticated) { // We can't send out any stanzas when there's no authenticated connection. // This can happen when the connection reconnects. return; } var stat = this.xmppstatus.getStatus(); this.idle_seconds++; if (this.csi_waiting_time > 0 && this.idle_seconds > this.csi_waiting_time && !this.inactive) { this.sendCSI(converse.INACTIVE); } if (this.auto_away > 0 && this.idle_seconds > this.auto_away && stat !== 'away' && stat !== 'xa') { this.auto_changed_status = true; this.xmppstatus.setStatus('away'); } else if (this.auto_xa > 0 && this.idle_seconds > this.auto_xa && stat !== 'xa') { this.auto_changed_status = true; this.xmppstatus.setStatus('xa'); } }; this.registerIntervalHandler = function () { /* Set an interval of one second and register a handler for it. * Required for the auto_away, auto_xa and csi_waiting_time features. */ if (this.auto_away < 1 && this.auto_xa < 1 && this.csi_waiting_time < 1) { // Waiting time of less then one second means features aren't used. return; } this.idle_seconds = 0; this.auto_changed_status = false; // Was the user's status changed by converse.js? $(window).on('click mousemove keypress focus'+unloadevent , this.onUserActivity.bind(this)); window.setInterval(this.onEverySecond.bind(this), 1000); }; this.giveFeedback = function (message, klass) { $('.conn-feedback').each(function (idx, el) { var $el = $(el); $el.addClass('conn-feedback').text(message); if (klass) { $el.addClass(klass); } else { $el.removeClass('error'); } }); }; this.rejectPresenceSubscription = function (jid, message) { /* Reject or cancel another user's subscription to our presence updates. * Parameters: * (String) jid - The Jabber ID of the user whose subscription * is being canceled. * (String) message - An optional message to the user */ var pres = $pres({to: jid, type: "unsubscribed"}); if (message && message !== "") { pres.c("status").t(message); } converse.connection.send(pres); }; this.getVCard = function (jid, callback, errback) { /* Request the VCard of another user. * * Parameters: * (String) jid - The Jabber ID of the user whose VCard is being requested. * (Function) callback - A function to call once the VCard is returned * (Function) errback - A function to call if an error occured * while trying to fetch the VCard. */ if (!this.use_vcards) { if (callback) { callback(jid, jid); } return; } converse.connection.vcard.get( function (iq) { // Successful callback var $vcard = $(iq).find('vCard'); var fullname = $vcard.find('FN').text(), img = $vcard.find('BINVAL').text(), img_type = $vcard.find('TYPE').text(), url = $vcard.find('URL').text(); if (jid) { var contact = converse.roster.get(jid); if (contact) { fullname = _.isEmpty(fullname)? contact.get('fullname') || jid: fullname; contact.save({ 'fullname': fullname, 'image_type': img_type, 'image': img, 'url': url, 'vcard_updated': moment().format() }); } } if (callback) { callback(iq, jid, fullname, img, img_type, url); } }.bind(this), jid, function (iq) { // Error callback var contact = converse.roster.get(jid); if (contact) { contact.save({ 'vcard_updated': moment().format() }); } if (errback) { errback(iq, jid); } } ); }; this.reconnect = function (condition) { converse.log('Attempting to reconnect in 5 seconds'); converse.giveFeedback(__('Attempting to reconnect in 5 seconds'), 'error'); window.clearTimeout(converse.reconnectTimeout); converse.reconnectTimeout = window.setTimeout(function () { if (converse.authentication !== "prebind") { this.connection.connect( this.connection.jid, this.connection.pass, function (status, condition) { this.onConnectStatusChanged(status, condition, true); }.bind(this), this.connection.wait, this.connection.hold, this.connection.route ); } else if (converse.prebind_url) { this.clearSession(); this._tearDown(); this.startNewBOSHSession(); } }.bind(this), 5000); }; this.onConnectStatusChanged = function (status, condition, reconnect) { converse.log("Status changed to: "+PRETTY_CONNECTION_STATUS[status]); if (status === Strophe.Status.CONNECTED || status === Strophe.Status.ATTACHED) { // By default we always want to send out an initial presence stanza. converse.send_initial_presence = true; delete converse.disconnection_cause; if (!!converse.reconnectTimeout) { window.clearTimeout(converse.reconnectTimeout); delete converse.reconnectTimeout; } if ((typeof reconnect !== 'undefined') && (reconnect)) { converse.log(status === Strophe.Status.CONNECTED ? 'Reconnected' : 'Reattached'); converse.onReconnected(); } else { converse.log(status === Strophe.Status.CONNECTED ? 'Connected' : 'Attached'); if (converse.connection.restored) { converse.send_initial_presence = false; // No need to send an initial presence stanza when // we're restoring an existing session. } converse.onConnected(); } } else if (status === Strophe.Status.DISCONNECTED) { if (converse.disconnection_cause === Strophe.Status.CONNFAIL && converse.auto_reconnect) { converse.reconnect(condition); } else { converse.renderLoginPanel(); } } else if (status === Strophe.Status.ERROR) { converse.giveFeedback(__('Error'), 'error'); } else if (status === Strophe.Status.CONNECTING) { converse.giveFeedback(__('Connecting')); } else if (status === Strophe.Status.AUTHENTICATING) { converse.giveFeedback(__('Authenticating')); } else if (status === Strophe.Status.AUTHFAIL) { converse.giveFeedback(__('Authentication Failed'), 'error'); converse.connection.disconnect(__('Authentication Failed')); converse.disconnection_cause = Strophe.Status.AUTHFAIL; } else if (status === Strophe.Status.CONNFAIL) { converse.disconnection_cause = Strophe.Status.CONNFAIL; } else if (status === Strophe.Status.DISCONNECTING) { // FIXME: what about prebind? if (!converse.connection.connected) { converse.renderLoginPanel(); } if (condition) { converse.giveFeedback(condition, 'error'); } } }; this.applyDragResistance = function (value, default_value) { /* This method applies some resistance around the * default_value. If value is close enough to * default_value, then default_value is returned instead. */ if (typeof value === 'undefined') { return undefined; } else if (typeof default_value === 'undefined') { return value; } var resistance = 10; if ((value !== default_value) && (Math.abs(value- default_value) < resistance)) { return default_value; } return value; }; this.updateMsgCounter = function () { if (this.msg_counter > 0) { if (document.title.search(/^Messages \(\d+\) /) === -1) { document.title = "Messages (" + this.msg_counter + ") " + document.title; } else { document.title = document.title.replace(/^Messages \(\d+\) /, "Messages (" + this.msg_counter + ") "); } window.blur(); window.focus(); } else if (document.title.search(/^Messages \(\d+\) /) !== -1) { document.title = document.title.replace(/^Messages \(\d+\) /, ""); } }; this.incrementMsgCounter = function () { this.msg_counter += 1; this.updateMsgCounter(); }; this.clearMsgCounter = function () { this.msg_counter = 0; this.updateMsgCounter(); }; this.initStatus = function (callback) { this.xmppstatus = new this.XMPPStatus(); var id = b64_sha1('converse.xmppstatus-'+converse.bare_jid); this.xmppstatus.id = id; // Appears to be necessary for backbone.browserStorage this.xmppstatus.browserStorage = new Backbone.BrowserStorage[converse.storage](id); this.xmppstatus.fetch({success: callback, error: callback}); }; this.initSession = function () { this.session = new this.Session(); var id = b64_sha1('converse.bosh-session'); this.session.id = id; // Appears to be necessary for backbone.browserStorage this.session.browserStorage = new Backbone.BrowserStorage[converse.storage](id); this.session.fetch(); }; this.clearSession = function () { if (this.roster) { this.roster.browserStorage._clear(); } this.session.browserStorage._clear(); }; this.logOut = function () { converse.auto_login = false; converse.chatboxviews.closeAllChatBoxes(); converse.clearSession(); converse.connection.disconnect(); }; this.registerGlobalEventHandlers = function () { $(document).on('mousemove', function (ev) { if (!this.resizing || !this.allow_dragresize) { return true; } ev.preventDefault(); this.resizing.chatbox.resizeChatBox(ev); }.bind(this)); $(document).on('mouseup', function (ev) { if (!this.resizing || !this.allow_dragresize) { return true; } ev.preventDefault(); var height = this.applyDragResistance( this.resizing.chatbox.height, this.resizing.chatbox.model.get('default_height') ); var width = this.applyDragResistance( this.resizing.chatbox.width, this.resizing.chatbox.model.get('default_width') ); if (this.connection.connected) { this.resizing.chatbox.model.save({'height': height}); this.resizing.chatbox.model.save({'width': width}); } else { this.resizing.chatbox.model.set({'height': height}); this.resizing.chatbox.model.set({'width': width}); } this.resizing = null; }.bind(this)); $(window).on("blur focus", function (ev) { if ((converse.windowState !== ev.type) && (ev.type === 'focus')) { converse.clearMsgCounter(); } converse.windowState = ev.type; }); $(window).on("resize", _.debounce(function (ev) { this.chatboxviews.trimChats(); }.bind(this), 200)); }; this.onReconnected = function () { // We need to re-register all the event handlers on the newly // created connection. var deferred = new $.Deferred(); this.initStatus(function () { // FIXME: leaky abstraction from RosterView this.rosterview.registerRosterXHandler(); this.rosterview.registerPresenceHandler(); this.chatboxes.registerMessageHandler(); this.xmppstatus.sendPresence(); this.giveFeedback(__('Contacts')); deferred.resolve(); }.bind(this)); return deferred.promise(); }; this.enableCarbons = function () { /* Ask the XMPP server to enable Message Carbons * See XEP-0280 https://xmpp.org/extensions/xep-0280.html#enabling */ if (!this.message_carbons || this.session.get('carbons_enabled')) { return; } var carbons_iq = new Strophe.Builder('iq', { from: this.connection.jid, id: 'enablecarbons', type: 'set' }) .c('enable', {xmlns: Strophe.NS.CARBONS}); this.connection.addHandler(function (iq) { if ($(iq).find('error').length > 0) { converse.log('ERROR: An error occured while trying to enable message carbons.'); } else { this.session.save({carbons_enabled: true}); converse.log('Message carbons have been enabled.'); } }.bind(this), null, "iq", null, "enablecarbons"); this.connection.send(carbons_iq); }; this.onConnected = function (callback) { // When reconnecting, there might be some open chat boxes. We don't // know whether these boxes are of the same account or not, so we // close them now. var deferred = new $.Deferred(); this.chatboxviews.closeAllChatBoxes(); this.jid = this.connection.jid; this.bare_jid = Strophe.getBareJidFromJid(this.connection.jid); this.resource = Strophe.getResourceFromJid(this.connection.jid); this.domain = Strophe.getDomainFromJid(this.connection.jid); this.minimized_chats = new converse.MinimizedChats({model: this.chatboxes}); this.features = new this.Features(); this.enableCarbons(); this.initStatus(function () { this.registerIntervalHandler(); this.roster = new this.RosterContacts(); this.roster.browserStorage = new Backbone.BrowserStorage[this.storage]( b64_sha1('converse.contacts-'+this.bare_jid)); this.chatboxes.onConnected(); this.giveFeedback(__('Contacts')); if (typeof this.callback === 'function') { // A callback method may be passed in via the // converse.initialize method. // XXX: Can we use $.Deferred instead of this callback? if (this.connection.service === 'jasmine tests') { // XXX: Call back with the internal converse object. This // object should never be exposed to production systems. // 'jasmine tests' is an invalid http bind service value, // so we're sure that this is just for tests. this.callback(this); } else { this.callback(); } } deferred.resolve(); }.bind(this)); converse.emit('ready'); return deferred.promise(); }; this.RosterContact = Backbone.Model.extend({ initialize: function (attributes, options) { var jid = attributes.jid; var bare_jid = Strophe.getBareJidFromJid(jid); var resource = Strophe.getResourceFromJid(jid); attributes.jid = bare_jid; this.set(_.extend({ 'id': bare_jid, 'jid': bare_jid, 'fullname': bare_jid, 'chat_status': 'offline', 'user_id': Strophe.getNodeFromJid(jid), 'resources': resource ? [resource] : [], 'groups': [], 'image_type': 'image/png', 'image': "iVBORw0KGgoAAAANSUhEUgAAAGAAAABgCAIAAABt+uBvAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3gwHCy455JBsggAABkJJREFUeNrtnM1PE1sUwHvvTD8otWLHST/Gimi1CEgr6M6FEWuIBo2pujDVsNDEP8GN/4MbN7oxrlipG2OCgZgYlxAbkRYw1KqkIDRCSkM7nXvvW8x7vjyNeQ9m7p1p3z1LQk/v/Dhz7vkEXL161cHl9wI5Ag6IA+KAOCAOiAPigDggLhwQB2S+iNZ+PcYY/SWEEP2HAAAIoSAIoihCCP+ngDDGtVotGAz29/cfOXJEUZSOjg6n06lp2sbGRqlUWlhYyGazS0tLbrdbEASrzgksyeYJId3d3el0uqenRxRFAAAA4KdfIIRgjD9+/Pj8+fOpqSndslofEIQwHA6Pjo4mEon//qmFhYXHjx8vLi4ihBgDEnp7e9l8E0Jo165dQ0NDd+/eDYVC2/qsJElDQ0OEkKWlpa2tLZamxAhQo9EIBoOjo6MXL17csZLe3l5FUT59+lQul5l5JRaAVFWNRqN37tw5ceKEQVWRSOTw4cOFQuHbt2+iKLYCIISQLMu3b99OJpOmKAwEAgcPHszn8+vr6wzsiG6UQQhxuVyXLl0aGBgwUW0sFstkMl6v90fo1KyAMMYDAwPnzp0zXfPg4GAqlWo0Gk0MiBAiy/L58+edTqf5Aa4onj59OhaLYYybFRCEMBaL0fNxBw4cSCQStN0QRUBut3t4eJjq6U+dOiVJElVPRBFQIBDo6+ujCqirqyscDlONGykC2lYyYSR6pBoQQapHZwAoHo/TuARYAOrs7GQASFEUqn6aIiBJkhgA6ujooFpUo6iaTa7koFwnaoWadLNe81tbWwzoaJrWrICWl5cZAFpbW6OabVAEtLi4yABQsVjUNK0pAWWzWQaAcrlcswKanZ1VVZUqHYRQEwOq1Wpv3ryhCmh6erpcLjdrNl+v1ycnJ+l5UELI27dvv3//3qxxEADgy5cvExMT9Mznw4cPtFtAdAPFarU6Pj5eKpVM17yxsfHy5cvV1VXazXu62gVBKBQKT58+rdVqJqrFGL948eLdu3dU8/g/H4FBUaJYLAqC0NPTY9brMD4+PjY25mDSracOCABACJmZmXE6nUePHjWu8NWrV48ePSKEsGlAs7Agfd5nenq6Wq0mk0kjDzY2NvbkyRMIIbP2PLvhBUEQ8vl8NpuNx+M+n29bzhVjvLKycv/+/YmJCcazQuwA6YzW1tYmJyf1SY+2trZ/rRk1Go1SqfT69esHDx4UCgVmNaa/zZ/9ABUhRFXVYDB48uTJeDweiUQkSfL7/T9MA2NcqVTK5fLy8vL8/PzU1FSxWHS5XJaM4wGr9sUwxqqqer3eUCgkSZJuUBBCfTRvc3OzXC6vrKxUKhWn02nhCJ5lM4oQQo/HgxD6+vXr58+fHf8sDOp+HQDg8XgclorFU676dKLlo6yWRdItIBwQB8QBcUCtfosRQjRNQwhhjPUC4w46WXryBSHU1zgEQWBz99EFhDGu1+t+v//48ePxeFxRlD179ng8nh0Efgiher2+vr6ur3HMzMysrq7uTJVdACGEurq6Ll++nEgkPB7Pj9jPoDHqOxyqqubz+WfPnuVyuV9XPeyeagAAAoHArVu3BgcHab8CuVzu4cOHpVKJUnfA5GweY+xyuc6cOXPv3r1IJMLAR8iyPDw8XK/Xi8Wiqqqmm5KZgBBC7e3tN27cuHbtGuPVpf7+/lAoNDs7W61WzfVKpgHSSzw3b95MpVKW3MfRaDQSiczNzVUqFRMZmQOIEOL1eq9fv3727FlL1t50URRFluX5+flqtWpWEGAOIFEUU6nUlStXLKSjy759+xwOx9zcnKZpphzGHMzhcDiTydgk9r1w4YIp7RPTAAmCkMlk2FeLf/tIEKbTab/fbwtAhJBoNGrutpNx6e7uPnTokC1eMU3T0um0DZPMkZER6wERQnw+n/FFSxpy7Nix3bt3WwwIIcRgIWnHkkwmjecfRgGx7DtuV/r6+iwGhDHev3+/bQF1dnYaH6E2CkiWZdsC2rt3r8WAHA5HW1ubbQGZcjajgOwTH/4qNko1Wlg4IA6IA+KAOKBWBUQIsfNojyliKIoRRfH9+/dut9umf3wzpoUNNQ4BAJubmwz+ic+OxefzWWlBhJD29nbug7iT5sIBcUAcEAfEAXFAHBAHxOVn+QMrmWpuPZx12gAAAABJRU5ErkJggg==", 'status': '' }, attributes)); this.on('destroy', function () { this.removeFromRoster(); }.bind(this)); }, subscribe: function (message) { /* Send a presence subscription request to this roster contact * * Parameters: * (String) message - An optional message to explain the * reason for the subscription request. */ this.save('ask', "subscribe"); // ask === 'subscribe' Means we have ask to subscribe to them. var pres = $pres({to: this.get('jid'), type: "subscribe"}); if (message && message !== "") { pres.c("status").t(message).up(); } var nick = converse.xmppstatus.get('fullname'); if (nick && nick !== "") { pres.c('nick', {'xmlns': Strophe.NS.NICK}).t(nick).up(); } converse.connection.send(pres); return this; }, ackSubscribe: function () { /* Upon receiving the presence stanza of type "subscribed", * the user SHOULD acknowledge receipt of that subscription * state notification by sending a presence stanza of type * "subscribe" to the contact */ converse.connection.send($pres({ 'type': 'subscribe', 'to': this.get('jid') })); }, ackUnsubscribe: function (jid) { /* Upon receiving the presence stanza of type "unsubscribed", * the user SHOULD acknowledge receipt of that subscription state * notification by sending a presence stanza of type "unsubscribe" * this step lets the user's server know that it MUST no longer * send notification of the subscription state change to the user. * Parameters: * (String) jid - The Jabber ID of the user who is unsubscribing */ converse.connection.send($pres({'type': 'unsubscribe', 'to': this.get('jid')})); this.destroy(); // Will cause removeFromRoster to be called. }, unauthorize: function (message) { /* Unauthorize this contact's presence subscription * Parameters: * (String) message - Optional message to send to the person being unauthorized */ converse.rejectPresenceSubscription(this.get('jid'), message); return this; }, authorize: function (message) { /* Authorize presence subscription * Parameters: * (String) message - Optional message to send to the person being authorized */ var pres = $pres({to: this.get('jid'), type: "subscribed"}); if (message && message !== "") { pres.c("status").t(message); } converse.connection.send(pres); return this; }, removeResource: function (resource) { var resources = this.get('resources'), idx; if (resource) { idx = _.indexOf(resources, resource); if (idx !== -1) { resources.splice(idx, 1); this.save({'resources': resources}); } } else { // if there is no resource (resource is null), it probably // means that the user is now completely offline. To make sure // that there isn't any "ghost" resources left, we empty the array this.save({'resources': []}); return 0; } return resources.length; }, removeFromRoster: function (callback) { /* Instruct the XMPP server to remove this contact from our roster * Parameters: * (Function) callback */ var iq = $iq({type: 'set'}) .c('query', {xmlns: Strophe.NS.ROSTER}) .c('item', {jid: this.get('jid'), subscription: "remove"}); converse.connection.sendIQ(iq, callback, callback); return this; }, showInRoster: function () { var chatStatus = this.get('chat_status'); if ((converse.show_only_online_users && chatStatus !== 'online') || (converse.hide_offline_users && chatStatus === 'offline')) { // If pending or requesting, show if ((this.get('ask') === 'subscribe') || (this.get('subscription') === 'from') || (this.get('requesting') === true)) { return true; } return false; } return true; } }); this.RosterContacts = Backbone.Collection.extend({ model: converse.RosterContact, comparator: function (contact1, contact2) { var name1, name2; var status1 = contact1.get('chat_status') || 'offline'; var status2 = contact2.get('chat_status') || 'offline'; if (converse.STATUS_WEIGHTS[status1] === converse.STATUS_WEIGHTS[status2]) { name1 = contact1.get('fullname').toLowerCase(); name2 = contact2.get('fullname').toLowerCase(); return name1 < name2 ? -1 : (name1 > name2? 1 : 0); } else { return converse.STATUS_WEIGHTS[status1] < converse.STATUS_WEIGHTS[status2] ? -1 : 1; } }, subscribeToSuggestedItems: function (msg) { $(msg).find('item').each(function (i, items) { if (this.getAttribute('action') === 'add') { converse.roster.addAndSubscribe( this.getAttribute('jid'), null, converse.xmppstatus.get('fullname')); } }); return true; }, isSelf: function (jid) { return (Strophe.getBareJidFromJid(jid) === Strophe.getBareJidFromJid(converse.connection.jid)); }, addAndSubscribe: function (jid, name, groups, message, attributes) { /* Add a roster contact and then once we have confirmation from * the XMPP server we subscribe to that contact's presence updates. * Parameters: * (String) jid - The Jabber ID of the user being added and subscribed to. * (String) name - The name of that user * (Array of Strings) groups - Any roster groups the user might belong to * (String) message - An optional message to explain the * reason for the subscription request. * (Object) attributes - Any additional attributes to be stored on the user's model. */ this.addContact(jid, name, groups, attributes).done(function (contact) { if (contact instanceof converse.RosterContact) { contact.subscribe(message); } }); }, sendContactAddIQ: function (jid, name, groups, callback, errback) { /* Send an IQ stanza to the XMPP server to add a new roster contact. * * Parameters: * (String) jid - The Jabber ID of the user being added * (String) name - The name of that user * (Array of Strings) groups - Any roster groups the user might belong to * (Function) callback - A function to call once the VCard is returned * (Function) errback - A function to call if an error occured */ name = _.isEmpty(name)? jid: name; var iq = $iq({type: 'set'}) .c('query', {xmlns: Strophe.NS.ROSTER}) .c('item', { jid: jid, name: name }); _.map(groups, function (group) { iq.c('group').t(group).up(); }); converse.connection.sendIQ(iq, callback, errback); }, addContact: function (jid, name, groups, attributes) { /* Adds a RosterContact instance to converse.roster and * registers the contact on the XMPP server. * Returns a promise which is resolved once the XMPP server has * responded. * * Parameters: * (String) jid - The Jabber ID of the user being added and subscribed to. * (String) name - The name of that user * (Array of Strings) groups - Any roster groups the user might belong to * (Object) attributes - Any additional attributes to be stored on the user's model. */ var deferred = new $.Deferred(); groups = groups || []; name = _.isEmpty(name)? jid: name; this.sendContactAddIQ(jid, name, groups, function (iq) { var contact = this.create(_.extend({ ask: undefined, fullname: name, groups: groups, jid: jid, requesting: false, subscription: 'none' }, attributes), {sort: false}); deferred.resolve(contact); }.bind(this), function (err) { alert(__("Sorry, there was an error while trying to add "+name+" as a contact.")); converse.log(err); deferred.resolve(err); } ); return deferred.promise(); }, addResource: function (bare_jid, resource) { var item = this.get(bare_jid), resources; if (item) { resources = item.get('resources'); if (resources) { if (_.indexOf(resources, resource) === -1) { resources.push(resource); item.set({'resources': resources}); } } else { item.set({'resources': [resource]}); } } }, subscribeBack: function (bare_jid) { var contact = this.get(bare_jid); if (contact instanceof converse.RosterContact) { contact.authorize().subscribe(); } else { // Can happen when a subscription is retried or roster was deleted this.addContact(bare_jid, '', [], { 'subscription': 'from' }).done(function (contact) { if (contact instanceof converse.RosterContact) { contact.authorize().subscribe(); } }); } }, getNumOnlineContacts: function () { var count = 0, ignored = ['offline', 'unavailable'], models = this.models, models_length = models.length, i; if (converse.show_only_online_users) { ignored = _.union(ignored, ['dnd', 'xa', 'away']); } for (i=0; i 0, fullname = this.get('fullname'), is_groupchat = $message.attr('type') === 'groupchat', msgid = $message.attr('id'), chat_state = $message.find(converse.COMPOSING).length && converse.COMPOSING || $message.find(converse.PAUSED).length && converse.PAUSED || $message.find(converse.INACTIVE).length && converse.INACTIVE || $message.find(converse.ACTIVE).length && converse.ACTIVE || $message.find(converse.GONE).length && converse.GONE, stamp, time, sender, from; if (is_groupchat) { from = Strophe.unescapeNode(Strophe.getResourceFromJid($message.attr('from'))); } else { from = Strophe.getBareJidFromJid($message.attr('from')); } fullname = (_.isEmpty(fullname) ? from: fullname).split(' ')[0]; if (delayed) { stamp = $delay.attr('stamp'); time = stamp; } else { time = moment().format(); } if ((is_groupchat && from === this.get('nick')) || (!is_groupchat && from === converse.bare_jid)) { sender = 'me'; } else { sender = 'them'; } this.messages.create({ chat_state: chat_state, delayed: delayed, fullname: fullname, message: body || undefined, msgid: msgid, sender: sender, time: time, archive_id: archive_id }); } }); this.ChatBoxView = Backbone.View.extend({ length: 200, tagName: 'div', className: 'chatbox', is_chatroom: false, // This is not a multi-user chatroom events: { 'click .close-chatbox-button': 'close', 'click .toggle-chatbox-button': 'minimize', 'keypress textarea.chat-textarea': 'keyPressed', 'click .toggle-smiley': 'toggleEmoticonMenu', 'click .toggle-smiley ul li': 'insertEmoticon', 'click .toggle-clear': 'clearMessages', 'click .toggle-call': 'toggleCall', 'mousedown .dragresize-top': 'onStartVerticalResize', 'mousedown .dragresize-left': 'onStartHorizontalResize', 'mousedown .dragresize-topleft': 'onStartDiagonalResize' }, initialize: function () { $(window).on('resize', _.debounce(this.setDimensions.bind(this), 100)); this.model.messages.on('add', this.onMessageAdded, this); this.model.on('show', this.show, this); this.model.on('destroy', this.hide, this); // TODO check for changed fullname as well this.model.on('change:chat_state', this.sendChatState, this); this.model.on('change:chat_status', this.onChatStatusChanged, this); this.model.on('change:image', this.renderAvatar, this); this.model.on('change:minimized', this.onMinimizedChanged, this); this.model.on('change:status', this.onStatusChanged, this); this.model.on('showHelpMessages', this.showHelpMessages, this); this.model.on('sendMessage', this.sendMessage, this); this.updateVCard().render().fetchMessages().insertIntoPage().hide(); }, render: function () { this.$el.attr('id', this.model.get('box_id')) .html(converse.templates.chatbox( _.extend(this.model.toJSON(), { show_toolbar: converse.show_toolbar, info_close: __('Close this chat box'), info_minimize: __('Minimize this chat box'), info_view: __('View more information on this person'), label_personal_message: __('Personal message') } ) ) ); this.setWidth(); this.$content = this.$el.find('.chat-content'); this.renderToolbar().renderAvatar(); this.$content.on('scroll', _.debounce(this.onScroll.bind(this), 100)); converse.emit('chatBoxOpened', this); window.setTimeout(utils.refreshWebkit, 50); return this.showStatusMessage(); }, setWidth: function () { // If a custom width is applied (due to drag-resizing), // then we need to set the width of the .chatbox element as well. if (this.model.get('width')) { this.$el.css('width', this.model.get('width')); } }, onScroll: function (ev) { if ($(ev.target).scrollTop() === 0 && this.model.messages.length) { this.fetchArchivedMessages({ 'before': this.model.messages.at(0).get('archive_id'), 'with': this.model.get('jid'), 'max': converse.archived_messages_page_size }); } }, fetchMessages: function () { /* Responsible for fetching previously sent messages, first * from session storage, and then once that's done by calling * fetchArchivedMessages, which fetches from the XMPP server if * applicable. */ this.model.messages.fetch({ 'add': true, 'success': function () { if (!converse.features.findWhere({'var': Strophe.NS.MAM})) { return; } if (this.model.messages.length < converse.archived_messages_page_size) { this.fetchArchivedMessages({ 'before': '', // Page backwards from the most recent message 'with': this.model.get('jid'), 'max': converse.archived_messages_page_size }); } }.bind(this) }); return this; }, fetchArchivedMessages: function (options) { /* Fetch archived chat messages from the XMPP server. * * Then, upon receiving them, call onMessage on the chat box, * so that they are displayed inside it. */ 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.queryForArchivedMessages(options, function (messages) { this.clearSpinner(); if (messages.length) { _.map(messages, converse.chatboxes.onMessage.bind(converse.chatboxes)); } }.bind(this), function () { this.clearSpinner(); converse.log("Error while trying to fetch archived messages", "error"); }.bind(this) ); }, insertIntoPage: function () { /* This method gets overridden in src/converse-controlbox.js if * the controlbox plugin is active. */ $('#conversejs').prepend(this.$el); return this; }, adjustToViewport: function () { /* Event handler called when viewport gets resized. We remove * custom width/height from chat boxes. */ var viewport_width = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); var viewport_height = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); if (viewport_width <= 480) { this.model.set('height', undefined); this.model.set('width', undefined); } else if (viewport_width <= this.model.get('width')) { this.model.set('width', undefined); } else if (viewport_height <= this.model.get('height')) { this.model.set('height', undefined); } }, initDragResize: function () { /* Determine and store the default box size. * We need this information for the drag-resizing feature. */ var $flyout = this.$el.find('.box-flyout'); if (typeof this.model.get('height') === 'undefined') { var height = $flyout.height(); var width = $flyout.width(); this.model.set('height', height); this.model.set('default_height', height); this.model.set('width', width); this.model.set('default_width', width); } var min_width = $flyout.css('min-width'); var min_height = $flyout.css('min-height'); this.model.set('min_width', min_width.endsWith('px') ? Number(min_width.replace(/px$/, '')) :0); this.model.set('min_height', min_height.endsWith('px') ? Number(min_height.replace(/px$/, '')) :0); // Initialize last known mouse position this.prev_pageY = 0; this.prev_pageX = 0; if (converse.connection.connected) { this.height = this.model.get('height'); this.width = this.model.get('width'); } return this; }, setDimensions: function () { // Make sure the chat box has the right height and width. this.adjustToViewport(); this.setChatBoxHeight(this.model.get('height')); this.setChatBoxWidth(this.model.get('width')); }, clearStatusNotification: function () { this.$content.find('div.chat-event').remove(); }, showStatusNotification: function (message, keep_old) { if (!keep_old) { this.clearStatusNotification(); } var was_at_bottom = this.$content.scrollTop() + this.$content.innerHeight() >= this.$content[0].scrollHeight; this.$content.append($('
').text(message)); if (was_at_bottom) { this.scrollDown(); } }, addSpinner: function () { if (!this.$content.first().hasClass('spinner')) { this.$content.prepend(''); } }, clearSpinner: function () { if (this.$content.children(':first').is('span.spinner')) { this.$content.children(':first').remove(); } }, prependDayIndicator: function (date) { /* Prepends an indicator into the chat area, showing the day as * given by the passed in date. * * Parameters: * (String) date - An ISO8601 date string. */ var day_date = moment(date).startOf('day'); this.$content.prepend(converse.templates.new_day({ isodate: day_date.format(), datestring: day_date.format("dddd MMM Do YYYY") })); }, appendMessage: function (attrs) { /* Helper method which appends a message to the end of the chat * box's content area. * * Parameters: * (Object) attrs: An object containing the message attributes. */ _.compose( _.debounce(this.scrollDown.bind(this), 50), this.$content.append.bind(this.$content) )(this.renderMessage(attrs)); }, showMessage: function (attrs) { /* Inserts a chat message into the content area of the chat box. * Will also insert a new day indicator if the message is on a * different day. * * The message to show may either be newer than the newest * message, or older than the oldest message. * * Parameters: * (Object) attrs: An object containing the message attributes. */ var $first_msg = this.$content.children('.chat-message:first'), first_msg_date = $first_msg.data('isodate'), last_msg_date, current_msg_date, day_date, $msgs, msg_dates, idx; if (!first_msg_date) { this.appendMessage(attrs); return; } current_msg_date = moment(attrs.time) || moment; last_msg_date = this.$content.children('.chat-message:last').data('isodate'); if (typeof last_msg_date !== "undefined" && (current_msg_date.isAfter(last_msg_date) || current_msg_date.isSame(last_msg_date))) { // The new message is after the last message if (current_msg_date.isAfter(last_msg_date, 'day')) { // Append a new day indicator day_date = moment(current_msg_date).startOf('day'); this.$content.append(converse.templates.new_day({ isodate: current_msg_date.format(), datestring: current_msg_date.format("dddd MMM Do YYYY") })); } this.appendMessage(attrs); return; } if (typeof first_msg_date !== "undefined" && (current_msg_date.isBefore(first_msg_date) || (current_msg_date.isSame(first_msg_date) && !current_msg_date.isSame(last_msg_date)))) { // The new message is before the first message if ($first_msg.prev().length === 0) { // There's no day indicator before the first message, so we prepend one. this.prependDayIndicator(first_msg_date); } if (current_msg_date.isBefore(first_msg_date, 'day')) { _.compose( this.scrollDownMessageHeight.bind(this), function ($el) { this.$content.prepend($el); return $el; }.bind(this) )(this.renderMessage(attrs)); // This message is on a different day, so we add a day indicator. this.prependDayIndicator(current_msg_date); } else { // The message is before the first, but on the same day. // We need to prepend the message immediately before the // first message (so that it'll still be after the day indicator). _.compose( this.scrollDownMessageHeight.bind(this), function ($el) { $el.insertBefore($first_msg); return $el; } )(this.renderMessage(attrs)); } } else { // We need to find the correct place to position the message current_msg_date = current_msg_date.format(); $msgs = this.$content.children('.chat-message'); msg_dates = _.map($msgs, function (el) { return $(el).data('isodate'); }); msg_dates.push(current_msg_date); msg_dates.sort(); idx = msg_dates.indexOf(current_msg_date)-1; _.compose( this.scrollDownMessageHeight.bind(this), function ($el) { $el.insertAfter(this.$content.find('.chat-message[data-isodate="'+msg_dates[idx]+'"]')); return $el; }.bind(this) )(this.renderMessage(attrs)); } }, renderMessage: function (attrs) { /* Renders a chat message based on the passed in attributes. * * Parameters: * (Object) attrs: An object containing the message attributes. * * Returns: * The DOM element representing the message. */ var msg_time = moment(attrs.time) || moment, text = attrs.message, match = text.match(/^\/(.*?)(?: (.*))?$/), fullname = this.model.get('fullname') || attrs.fullname, extra_classes = attrs.delayed && 'delayed' || '', template, username; if ((match) && (match[1] === 'me')) { text = text.replace(/^\/me/, ''); template = converse.templates.action; username = fullname; } else { template = converse.templates.message; username = attrs.sender === 'me' && __('me') || fullname; } this.$content.find('div.chat-event').remove(); // FIXME: leaky abstraction from MUC if (this.is_chatroom && attrs.sender === 'them' && (new RegExp("\\b"+this.model.get('nick')+"\\b")).test(text)) { // Add special class to mark groupchat messages in which we // are mentioned. extra_classes += ' mentioned'; } return $(template({ msgid: attrs.msgid, 'sender': attrs.sender, 'time': msg_time.format('hh:mm'), 'isodate': msg_time.format(), 'username': username, 'message': '', 'extra_classes': extra_classes })).children('.chat-msg-content').first().text(text) .addHyperlinks() .addEmoticons(converse.visible_toolbar_buttons.emoticons).parent(); }, showHelpMessages: function (msgs, type, spinner) { var i, msgs_length = msgs.length; for (i=0; i'+msgs[i]+'')); } if (spinner === true) { this.$content.append(''); } else if (spinner === false) { this.$content.find('span.spinner').remove(); } return this.scrollDown(); }, handleChatStateMessage: function (message) { if (message.get('chat_state') === converse.COMPOSING) { this.showStatusNotification(message.get('fullname')+' '+__('is typing')); this.clear_status_timeout = window.setTimeout(this.clearStatusNotification.bind(this), 10000); } else if (message.get('chat_state') === converse.PAUSED) { this.showStatusNotification(message.get('fullname')+' '+__('has stopped typing')); } else if (_.contains([converse.INACTIVE, converse.ACTIVE], message.get('chat_state'))) { this.$content.find('div.chat-event').remove(); } else if (message.get('chat_state') === converse.GONE) { this.showStatusNotification(message.get('fullname')+' '+__('has gone away')); } }, handleTextMessage: function (message) { this.showMessage(_.clone(message.attributes)); if ((message.get('sender') !== 'me') && (converse.windowState === 'blur')) { converse.incrementMsgCounter(); } if (!this.model.get('minimized') && !this.$el.is(':visible')) { this.show(); } }, onMessageAdded: function (message) { /* Handler that gets called when a new message object is created. * * Parameters: * (Object) message - The message Backbone object that was added. */ if (typeof this.clear_status_timeout !== 'undefined') { window.clearTimeout(this.clear_status_timeout); delete this.clear_status_timeout; } if (!message.get('message')) { this.handleChatStateMessage(message); } else { this.handleTextMessage(message); } }, createMessageStanza: function (message) { return $msg({ from: converse.connection.jid, to: this.model.get('jid'), type: 'chat', id: message.get('msgid') }).c('body').t(message.get('message')).up() .c(converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up(); }, sendMessage: function (message) { /* Responsible for sending off a text message. * * Parameters: * (Message) message - The chat message */ // TODO: We might want to send to specfic resources. // Especially in the OTR case. var messageStanza = this.createMessageStanza(message); converse.connection.send(messageStanza); if (converse.forward_messages) { // Forward the message, so that other connected resources are also aware of it. converse.connection.send( $msg({ to: converse.bare_jid, type: 'chat', id: message.get('msgid') }) .c('forwarded', {xmlns:'urn:xmpp:forward:0'}) .c('delay', {xmns:'urn:xmpp:delay',stamp:(new Date()).getTime()}).up() .cnode(messageStanza.tree()) ); } }, onMessageSubmitted: function (text) { /* This method gets called once the user has typed a message * and then pressed enter in a chat box. * * Parameters: * (string) text - The chat message text. */ if (!converse.connection.authenticated) { return this.showHelpMessages( ['Sorry, the connection has been lost, '+ 'and your message could not be sent'], 'error' ); } var match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/), msgs; if (match) { if (match[1] === "clear") { return this.clearMessages(); } else if (match[1] === "help") { msgs = [ '/help:'+__('Show this menu')+'', '/me:'+__('Write in the third person')+'', '/clear:'+__('Remove messages')+'' ]; this.showHelpMessages(msgs); return; } } var fullname = converse.xmppstatus.get('fullname'); fullname = _.isEmpty(fullname)? converse.bare_jid: fullname; var message = this.model.messages.create({ fullname: fullname, sender: 'me', time: moment().format(), message: text }); this.sendMessage(message); }, 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. */ converse.connection.send( $msg({'to':this.model.get('jid'), 'type': 'chat'}) .c(this.model.get('chat_state'), {'xmlns': Strophe.NS.CHATSTATES}) ); }, setChatState: function (state, no_save) { /* Mutator for setting the chat state of this chat session. * Handles clearing of any chat state notification timeouts and * setting new ones if necessary. * Timeouts are set when the state being set is COMPOSING or PAUSED. * After the timeout, COMPOSING will become PAUSED and PAUSED will become INACTIVE. * See XEP-0085 Chat State Notifications. * * Parameters: * (string) state - The chat state (consts ACTIVE, COMPOSING, PAUSED, INACTIVE, GONE) * (Boolean) no_save - Just do the cleanup or setup but don't actually save the state. */ if (typeof this.chat_state_timeout !== 'undefined') { window.clearTimeout(this.chat_state_timeout); delete this.chat_state_timeout; } if (state === converse.COMPOSING) { this.chat_state_timeout = window.setTimeout( this.setChatState.bind(this), converse.TIMEOUTS.PAUSED, converse.PAUSED); } else if (state === converse.PAUSED) { this.chat_state_timeout = window.setTimeout( this.setChatState.bind(this), converse.TIMEOUTS.INACTIVE, converse.INACTIVE); } if (!no_save && this.model.get('chat_state') !== state) { this.model.set('chat_state', state); } return this; }, keyPressed: function (ev) { /* Event handler for when a key is pressed in a chat box textarea. */ var $textarea = $(ev.target), message; if (ev.keyCode === KEY.ENTER) { ev.preventDefault(); message = $textarea.val(); $textarea.val('').focus(); if (message !== '') { if (this.model.get('chatroom')) { this.onChatRoomMessageSubmitted(message); } else { this.onMessageSubmitted(message); } converse.emit('messageSend', message); } this.setChatState(converse.ACTIVE); } else if (!this.model.get('chatroom')) { // chat state data is currently only for single user chat // Set chat state to composing if keyCode is not a forward-slash // (which would imply an internal command and not a message). this.setChatState(converse.COMPOSING, ev.keyCode === KEY.FORWARD_SLASH); } }, onStartVerticalResize: function (ev) { if (!converse.allow_dragresize) { return true; } // Record element attributes for mouseMove(). this.height = this.$el.children('.box-flyout').height(); converse.resizing = { 'chatbox': this, 'direction': 'top' }; this.prev_pageY = ev.pageY; }, onStartHorizontalResize: function (ev) { if (!converse.allow_dragresize) { return true; } this.width = this.$el.children('.box-flyout').width(); converse.resizing = { 'chatbox': this, 'direction': 'left' }; this.prev_pageX = ev.pageX; }, onStartDiagonalResize: function (ev) { this.onStartHorizontalResize(ev); this.onStartVerticalResize(ev); converse.resizing.direction = 'topleft'; }, setChatBoxHeight: function (height) { if (!this.model.get('minimized')) { if (height) { height = converse.applyDragResistance(height, this.model.get('default_height'))+'px'; } else { height = ""; } this.$el.children('.box-flyout')[0].style.height = height; } }, setChatBoxWidth: function (width) { if (!this.model.get('minimized')) { if (width) { width = converse.applyDragResistance(width, this.model.get('default_width'))+'px'; } else { width = ""; } this.$el[0].style.width = width; this.$el.children('.box-flyout')[0].style.width = width; } }, resizeChatBox: function (ev) { var diff; if (converse.resizing.direction.indexOf('top') === 0) { diff = ev.pageY - this.prev_pageY; if (diff) { this.height = ((this.height-diff) > (this.model.get('min_height') || 0)) ? (this.height-diff) : this.model.get('min_height'); this.prev_pageY = ev.pageY; this.setChatBoxHeight(this.height); } } if (converse.resizing.direction.indexOf('left') !== -1) { diff = this.prev_pageX - ev.pageX; if (diff) { this.width = ((this.width+diff) > (this.model.get('min_width') || 0)) ? (this.width+diff) : this.model.get('min_width'); this.prev_pageX = ev.pageX; this.setChatBoxWidth(this.width); } } }, clearMessages: function (ev) { if (ev && ev.preventDefault) { ev.preventDefault(); } var result = confirm(__("Are you sure you want to clear the messages from this chat box?")); if (result === true) { this.$content.empty(); this.model.messages.reset(); this.model.messages.browserStorage._clear(); } return this; }, insertEmoticon: function (ev) { ev.stopPropagation(); this.$el.find('.toggle-smiley ul').slideToggle(200); var $textbox = this.$el.find('textarea.chat-textarea'); var value = $textbox.val(); var $target = $(ev.target); $target = $target.is('a') ? $target : $target.children('a'); if (value && (value[value.length-1] !== ' ')) { value = value + ' '; } $textbox.focus().val(value+$target.data('emoticon')+' '); }, toggleEmoticonMenu: function (ev) { ev.stopPropagation(); this.$el.find('.toggle-smiley ul').slideToggle(200); }, toggleCall: function (ev) { ev.stopPropagation(); converse.emit('callButtonClicked', { connection: converse.connection, model: this.model }); }, onChatStatusChanged: function (item) { var chat_status = item.get('chat_status'), fullname = item.get('fullname'); fullname = _.isEmpty(fullname)? item.get('jid'): fullname; if (this.$el.is(':visible')) { if (chat_status === 'offline') { this.showStatusNotification(fullname+' '+__('has gone offline')); } else if (chat_status === 'away') { this.showStatusNotification(fullname+' '+__('has gone away')); } else if ((chat_status === 'dnd')) { this.showStatusNotification(fullname+' '+__('is busy')); } else if (chat_status === 'online') { this.$el.find('div.chat-event').remove(); } } converse.emit('contactStatusChanged', item.attributes); }, onStatusChanged: function (item) { this.showStatusMessage(); converse.emit('contactStatusMessageChanged', { 'contact': item.attributes, 'message': item.get('status') }); }, onMinimizedChanged: function (item) { if (item.get('minimized')) { this.hide(); } else { this.maximize(); } }, showStatusMessage: function (msg) { msg = msg || this.model.get('status'); if (typeof msg === "string") { this.$el.find('p.user-custom-message').text(msg).attr('title', msg); } return this; }, close: function (ev) { if (ev && ev.preventDefault) { ev.preventDefault(); } if (converse.connection.connected) { this.model.destroy(); this.setChatState(converse.INACTIVE); } else { this.hide(); } converse.emit('chatBoxClosed', this); return this; }, onShow: function () { converse.chatboxviews.trimChats(this); utils.refreshWebkit(); this.$content.scrollTop(this.model.get('scroll')); this.setChatState(converse.ACTIVE).focus(); converse.emit('chatBoxMaximized', this); }, maximize: function () { // Restore a minimized chat box $('#conversejs').prepend(this.$el); this.$el.show('fast', this.onShow.bind(this)); return this; }, minimize: function (ev) { if (ev && ev.preventDefault) { ev.preventDefault(); } // save the scroll position to restore it on maximize this.model.save({'scroll': this.$content.scrollTop()}); // Minimizes a chat box this.setChatState(converse.INACTIVE).model.minimize(); this.$el.hide('fast', utils.refreshwebkit); converse.emit('chatBoxMinimized', this); }, updateVCard: function () { if (!this.use_vcards) { return this; } var jid = this.model.get('jid'), contact = converse.roster.get(jid); if ((contact) && (!contact.get('vcard_updated'))) { converse.getVCard( jid, function (iq, jid, fullname, image, image_type, url) { this.model.save({ 'fullname' : fullname || jid, 'url': url, 'image_type': image_type, 'image': image }); }.bind(this), function () { converse.log("ChatBoxView.initialize: An error occured while fetching vcard"); } ); } return this; }, renderToolbar: function (options) { if (!converse.show_toolbar) { return; } options = _.extend(options || {}, { label_clear: __('Clear all messages'), label_hide_occupants: __('Hide the list of occupants'), label_insert_smiley: __('Insert a smiley'), label_start_call: __('Start a call'), show_call_button: converse.visible_toolbar_buttons.call, show_clear_button: converse.visible_toolbar_buttons.clear, show_emoticons: converse.visible_toolbar_buttons.emoticons, // FIXME Leaky abstraction MUC show_occupants_toggle: this.is_chatroom && converse.visible_toolbar_buttons.toggle_occupants }); this.$el.find('.chat-toolbar').html(converse.templates.toolbar(_.extend(this.model.toJSON(), options || {}))); return this; }, renderAvatar: function () { if (!this.model.get('image')) { return; } var img_src = 'data:'+this.model.get('image_type')+';base64,'+this.model.get('image'), canvas = $('').get(0); if (!(canvas.getContext && canvas.getContext('2d'))) { return this; } var ctx = canvas.getContext('2d'); var img = new Image(); // Create new Image object img.onload = function () { var ratio = img.width/img.height; if (ratio < 1) { ctx.drawImage(img, 0,0, 32, 32*(1/ratio)); } else { ctx.drawImage(img, 0,0, 32, 32*ratio); } }; img.src = img_src; this.$el.find('.chat-title').before(canvas); return this; }, focus: function () { this.$el.find('.chat-textarea').focus(); converse.emit('chatBoxFocused', this); return this; }, hide: function () { if (this.$el.is(':visible') && this.$el.css('opacity') === "1") { this.$el.hide(); utils.refreshWebkit(); } return this; }, show: _.debounce(function (focus) { if (this.$el.is(':visible') && this.$el.css('opacity') === "1") { if (focus) { this.focus(); } return this; } this.initDragResize().setDimensions(); this.$el.fadeIn(function () { if (converse.connection.connected) { // Without a connection, we haven't yet initialized // localstorage this.model.save(); } this.setChatState(converse.ACTIVE); this.scrollDown(); if (focus) { this.focus(); } }.bind(this)); return this; }, 250, true), scrollDownMessageHeight: function ($message) { if (this.$content.is(':visible')) { this.$content.scrollTop(this.$content.scrollTop() + $message[0].scrollHeight); } return this; }, scrollDown: function () { if (this.$content.is(':visible')) { this.$content.scrollTop(this.$content[0].scrollHeight); } return this; } }); this.ChatBoxes = Backbone.Collection.extend({ model: converse.ChatBox, comparator: 'time_opened', registerMessageHandler: function () { converse.connection.addHandler( function (message) { this.onMessage(message); return true; }.bind(this), null, 'message', 'chat'); }, onChatBoxFetched: function (collection, resp) { /* Show chat boxes upon receiving them from sessionStorage * * This method gets overridden entirely in src/converse-controlbox.js * if the controlbox plugin is active. */ collection.each(function (chatbox) { if (!chatbox.get('minimized')) { chatbox.trigger('show'); } }); }, onConnected: function () { this.browserStorage = new Backbone.BrowserStorage[converse.storage]( b64_sha1('converse.chatboxes-'+converse.bare_jid)); this.registerMessageHandler(); this.fetch({ add: true, success: this.onChatBoxFetched.bind(this) }); }, onMessage: function (message) { /* Handler method for all incoming single-user chat "message" stanzas. */ var $message = $(message), contact_jid, $forwarded, $delay, from_bare_jid, from_resource, is_me, msgid, chatbox, resource, from_jid = $message.attr('from'), to_jid = $message.attr('to'), to_resource = Strophe.getResourceFromJid(to_jid), archive_id = $message.find('result[xmlns="'+Strophe.NS.MAM+'"]').attr('id'); if (to_resource && to_resource !== converse.resource) { converse.log('Ignore incoming message intended for a different resource: '+to_jid, 'info'); return true; } if (from_jid === converse.connection.jid) { // FIXME: Forwarded messages should be sent to specific resources, not broadcasted converse.log("Ignore incoming message sent from this client's JID: "+from_jid, 'info'); return true; } $forwarded = $message.find('forwarded'); if ($forwarded.length) { $message = $forwarded.children('message'); $delay = $forwarded.children('delay'); from_jid = $message.attr('from'); to_jid = $message.attr('to'); } from_bare_jid = Strophe.getBareJidFromJid(from_jid); from_resource = Strophe.getResourceFromJid(from_jid); is_me = from_bare_jid === converse.bare_jid; msgid = $message.attr('id'); if (is_me) { // I am the sender, so this must be a forwarded message... contact_jid = Strophe.getBareJidFromJid(to_jid); resource = Strophe.getResourceFromJid(to_jid); } else { contact_jid = from_bare_jid; resource = from_resource; } // Get chat box, but only create a new one when the message has a body. chatbox = this.getChatBox(contact_jid, $message.find('body').length > 0); if (!chatbox) { return true; } if (msgid && chatbox.messages.findWhere({msgid: msgid})) { return true; // We already have this message stored. } chatbox.createMessage($message, $delay, archive_id); converse.roster.addResource(contact_jid, resource); converse.emit('message', message); return true; }, getChatBox: function (jid, create) { /* Returns a chat box or optionally return a newly * created one if one doesn't exist. * * Parameters: * (String) jid - The JID of the user whose chat box we want * (Boolean) create - Should a new chat box be created if none exists? */ jid = jid.toLowerCase(); var bare_jid = Strophe.getBareJidFromJid(jid); var chatbox = this.get(bare_jid); if (!chatbox && create) { var roster_item = converse.roster.get(bare_jid); if (roster_item === undefined) { converse.log('Could not get roster item for JID '+bare_jid, 'error'); return; } chatbox = this.create({ 'id': bare_jid, 'jid': bare_jid, 'fullname': _.isEmpty(roster_item.get('fullname'))? jid: roster_item.get('fullname'), 'image_type': roster_item.get('image_type'), 'image': roster_item.get('image'), 'url': roster_item.get('url') }); } return chatbox; } }); this.ChatBoxViews = Backbone.Overview.extend({ initialize: function () { this.model.on("add", this.onChatBoxAdded, this); this.model.on("change:minimized", function (item) { if (item.get('minimized') === true) { /* When a chat is minimized in trimChats, trimChats needs to be * called again (in case the minimized chats toggle is newly shown). */ this.trimChats(); } else { this.trimChats(this.get(item.get('id'))); } }, this); }, _ensureElement: function () { /* Override method from backbone.js * If the #conversejs element doesn't exist, create it. */ if (!this.el) { var $el = $('#conversejs'); if (!$el.length) { $el = $('
'); $('body').append($el); } $el.html(converse.templates.chats_panel()); this.setElement($el, false); } else { this.setElement(_.result(this, 'el'), false); } }, onChatBoxAdded: function (item) { var view = this.get(item.get('id')); if (!view) { view = new converse.ChatBoxView({model: item}); this.add(item.get('id'), view); } else { delete view.model; // Remove ref to old model to help garbage collection view.model = item; view.initialize(); } this.trimChats(view); }, getChatBoxWidth: function (view) { if (!view.model.get('minimized') && view.$el.is(':visible')) { return view.$el.outerWidth(true); } return 0; }, trimChats: function (newchat) { /* This method is called when a newly created chat box will * be shown. * * It checks whether there is enough space on the page to show * another chat box. Otherwise it minimize the oldest chat box * to create space. */ if (converse.no_trimming || (this.model.length <= 1)) { return; } var oldest_chat, $minimized = converse.minimized_chats.$el, minimized_width = _.contains(this.model.pluck('minimized'), true) ? $minimized.outerWidth(true) : 0, boxes_width = newchat ? newchat.$el.outerWidth(true) : 0, new_id = newchat ? newchat.model.get('id') : null; boxes_width += _.reduce(this.xget(new_id), function (memo, view) { return memo + this.getChatBoxWidth(view); }.bind(this), 0); if ((minimized_width + boxes_width) > $('body').outerWidth(true)) { oldest_chat = this.getOldestMaximizedChat([new_id]); if (oldest_chat) { oldest_chat.minimize(); } } }, getOldestMaximizedChat: function (exclude_ids) { // Get oldest view (if its id is not excluded) var i = 0; var model = this.model.sort().at(i); while (_.contains(exclude_ids, model.get('id')) || model.get('minimized') === true) { i++; model = this.model.at(i); if (!model) { return null; } } return model; }, closeAllChatBoxes: function () { /* This method gets overridden in src/converse-controlbox.js if * the controlbox plugin is active. */ this.each(function (view) { view.close(); }); return this; }, showChat: function (attrs) { /* Find the chat box and show it. If it doesn't exist, create it. */ var chatbox = this.model.get(attrs.jid); if (!chatbox) { chatbox = this.model.create(attrs, { 'error': function (model, response) { converse.log(response.responseText); } }); } if (chatbox.get('minimized')) { chatbox.maximize(); } else { chatbox.trigger('show', true); } return chatbox; } }); this.MinimizedChatBoxView = Backbone.View.extend({ tagName: 'div', className: 'chat-head', events: { 'click .close-chatbox-button': 'close', 'click .restore-chat': 'restore' }, initialize: function () { this.model.messages.on('add', function (m) { if (m.get('message')) { this.updateUnreadMessagesCounter(); } }, this); this.model.on('change:minimized', this.clearUnreadMessagesCounter, this); }, render: function () { var data = _.extend( this.model.toJSON(), { 'tooltip': __('Click to restore this chat') } ); if (this.model.get('chatroom')) { data.title = this.model.get('name'); this.$el.addClass('chat-head-chatroom'); } else { data.title = this.model.get('fullname'); this.$el.addClass('chat-head-chatbox'); } return this.$el.html(converse.templates.trimmed_chat(data)); }, clearUnreadMessagesCounter: function () { this.model.set({'num_unread': 0}); this.render(); }, updateUnreadMessagesCounter: function () { this.model.set({'num_unread': this.model.get('num_unread') + 1}); this.render(); }, close: function (ev) { if (ev && ev.preventDefault) { ev.preventDefault(); } this.remove(); this.model.destroy(); converse.emit('chatBoxClosed', this); return this; }, restore: _.debounce(function (ev) { if (ev && ev.preventDefault) { ev.preventDefault(); } this.model.messages.off('add',null,this); this.remove(); this.model.maximize(); }, 200, true) }); this.MinimizedChats = Backbone.Overview.extend({ el: "#minimized-chats", events: { "click #toggle-minimized-chats": "toggle" }, initialize: function () { this.initToggle(); this.model.on("add", this.onChanged, this); this.model.on("destroy", this.removeChat, this); this.model.on("change:minimized", this.onChanged, this); this.model.on('change:num_unread', this.updateUnreadMessagesCounter, this); }, tearDown: function () { this.model.off("add", this.onChanged); this.model.off("destroy", this.removeChat); this.model.off("change:minimized", this.onChanged); this.model.off('change:num_unread', this.updateUnreadMessagesCounter); return this; }, initToggle: function () { this.toggleview = new converse.MinimizedChatsToggleView({ model: new converse.MinimizedChatsToggle() }); var id = b64_sha1('converse.minchatstoggle'+converse.bare_jid); this.toggleview.model.id = id; // Appears to be necessary for backbone.browserStorage this.toggleview.model.browserStorage = new Backbone.BrowserStorage[converse.storage](id); this.toggleview.model.fetch(); }, render: function () { if (this.keys().length === 0) { this.$el.hide('fast'); } else if (this.keys().length === 1) { this.$el.show('fast'); } return this.$el; }, toggle: function (ev) { if (ev && ev.preventDefault) { ev.preventDefault(); } this.toggleview.model.save({'collapsed': !this.toggleview.model.get('collapsed')}); this.$('.minimized-chats-flyout').toggle(); }, onChanged: function (item) { if (item.get('minimized')) { this.addChat(item); } else if (this.get(item.get('id'))) { this.removeChat(item); } }, addChat: function (item) { var existing = this.get(item.get('id')); if (existing && existing.$el.parent().length !== 0) { return; } var view = new converse.MinimizedChatBoxView({model: item}); this.$('.minimized-chats-flyout').append(view.render()); this.add(item.get('id'), view); this.toggleview.model.set({'num_minimized': this.keys().length}); this.render(); }, removeChat: function (item) { this.remove(item.get('id')); this.toggleview.model.set({'num_minimized': this.keys().length}); this.render(); }, updateUnreadMessagesCounter: function () { var ls = this.model.pluck('num_unread'), count = 0, i; for (i=0; i