/*! * Converse.js (Web-based XMPP instant messaging client) * http://conversejs.org * * Copyright (c) 2012, Jan-Carel Brand * Dual licensed under the MIT and GPL Licenses */ // AMD/global registrations (function (root, factory) { if (typeof console === "undefined" || typeof console.log === "undefined") { console = { log: function () {}, error: function () {} }; } if (typeof define === 'function' && define.amd) { define("converse", [ "locales", "backbone.localStorage", "jquery.tinysort", "strophe", "strophe.muc", "strophe.roster", "strophe.vcard", "strophe.disco" ], function() { // Use Mustache style syntax for variable interpolation _.templateSettings = { evaluate : /\{\[([\s\S]+?)\]\}/g, interpolate : /\{\{([\s\S]+?)\}\}/g }; return factory(jQuery, _, console); } ); } else { // Browser globals _.templateSettings = { evaluate : /\{\[([\s\S]+?)\]\}/g, interpolate : /\{\{([\s\S]+?)\}\}/g }; root.converse = factory(jQuery, _, console || {log: function(){}}); } }(this, function ($, _, console) { var converse = {}; converse.initialize = function (settings, callback) { var converse = this; // Default configuration values // ---------------------------- this.allow_contact_requests = true; this.allow_muc = true; this.allow_otr = true; this.animate = true; this.auto_list_rooms = false; this.auto_subscribe = false; this.bosh_service_url = undefined; // The BOSH connection manager URL. this.debug = false; this.hide_muc_server = false; this.i18n = locales.en; this.prebind = false; this.show_controlbox_by_default = false; this.show_only_online_users = false; this.testing = false; // Exposes sensitive data for testing. Never set to true in production systems! this.xhr_custom_status = false; this.xhr_user_search = false; // Allow only whitelisted configuration attributes to be overwritten _.extend(this, _.pick(settings, [ 'allow_contact_requests', 'allow_muc', 'animate', 'auto_list_rooms', 'auto_subscribe', 'bosh_service_url', 'connection', 'debug', 'fullname', 'hide_muc_server', 'i18n', 'jid', 'prebind', 'rid', 'show_controlbox_by_default', 'show_only_online_users', 'sid', 'testing', 'xhr_custom_status', 'xhr_user_search' ])); // Translation machinery // --------------------- var __ = $.proxy(function (str) { /* Translation factory */ if (this.i18n === undefined) { this.i18n = locales['en']; } var t = this.i18n.translate(str); if (arguments.length>1) { return t.fetch.apply(t, [].slice.call(arguments,1)); } else { return t.fetch(); } }, this); var ___ = function (str) { /* XXX: This is part of a hack to get gettext to scan strings to be * translated. Strings we cannot send to the function above because * they require variable interpolation and we don't yet have the * variables at scan time. * * See actionInfoMessages */ return str; }; // Translation aware constants // --------------------------- var STATUSES = { 'dnd': __('This contact is busy'), 'online': __('This contact is online'), 'offline': __('This contact is offline'), 'unavailable': __('This contact is unavailable'), 'xa': __('This contact is away for an extended period'), 'away': __('This contact is away') }; // Module-level variables // ---------------------- this.callback = callback || function () {}; this.initial_presence_sent = 0; this.msg_counter = 0; // Module-level functions // ---------------------- this.autoLink = function (text) { // Convert URLs into hyperlinks var re = /((http|https|ftp):\/\/[\w?=&.\/\-;#~%\-]+(?![\w\s?&.\/;#~%"=\-]*>))/g; return text.replace(re, '$1'); }; this.giveFeedback = function (message, klass) { $('.conn-feedback').text(message); $('.conn-feedback').attr('class', 'conn-feedback'); if (klass) { $('.conn-feedback').addClass(klass); } }; this.log = function (txt) { if (this.debug) { console.log(txt); } }; this.getVCard = function (jid, callback, errback) { converse.connection.vcard.get( $.proxy(function (iq) { // Successful callback $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 rosteritem = converse.roster.get(jid); if (rosteritem) { rosteritem.save({ 'fullname': fullname || jid, 'image_type': img_type, 'image': img, 'url': url, 'vcard_updated': converse.toISOString(new Date()) }); } } if (callback) { callback(jid, fullname, img, img_type, url); } }, this), jid, function (iq) { // Error callback var rosteritem = converse.roster.get(jid); if (rosteritem) { rosteritem.save({ 'vcard_updated': converse.toISOString(new Date()) }); } if (errback) { errback(iq); } }); }; this.onConnect = function (status) { var $button, $form; if (status === Strophe.Status.CONNECTED) { converse.log('Connected'); converse.onConnected(); } else if (status === Strophe.Status.DISCONNECTED) { $form = $('#converse-login'); $button = $form.find('input[type=submit]'); if ($button) { $button.show().siblings('span').remove(); } converse.giveFeedback(__('Disconnected'), 'error'); converse.connection.connect( converse.connection.jid, converse.connection.pass, converse.onConnect ); } else if (status === Strophe.Status.Error) { $form = $('#converse-login'); $button = $form.find('input[type=submit]'); if ($button) { $button.show().siblings('span').remove(); } converse.giveFeedback(__('Error'), 'error'); } else if (status === Strophe.Status.CONNECTING) { converse.giveFeedback(__('Connecting')); } else if (status === Strophe.Status.CONNFAIL) { converse.chatboxesview.views.controlbox.trigger('connection-fail'); converse.giveFeedback(__('Connection Failed'), 'error'); } else if (status === Strophe.Status.AUTHENTICATING) { converse.giveFeedback(__('Authenticating')); } else if (status === Strophe.Status.AUTHFAIL) { converse.chatboxesview.views.controlbox.trigger('auth-fail'); converse.giveFeedback(__('Authentication Failed'), 'error'); } else if (status === Strophe.Status.DISCONNECTING) { converse.giveFeedback(__('Disconnecting'), 'error'); } else if (status === Strophe.Status.ATTACHED) { converse.log('Attached'); converse.onConnected(); } }; this.toISOString = function (date) { var pad; if (typeof date.toISOString !== 'undefined') { return date.toISOString(); } else { // IE <= 8 Doesn't have toISOStringMethod pad = function (num) { return (num < 10) ? '0' + num : '' + num; }; return date.getUTCFullYear() + '-' + pad(date.getUTCMonth() + 1) + '-' + pad(date.getUTCDate()) + 'T' + pad(date.getUTCHours()) + ':' + pad(date.getUTCMinutes()) + ':' + pad(date.getUTCSeconds()) + '.000Z'; } }; this.parseISO8601 = function (datestr) { /* Parses string formatted as 2013-02-14T11:27:08.268Z to a Date obj. */     var numericKeys = [1, 4, 5, 6, 7, 10, 11], struct = /^\s*(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}\.?\d*)Z\s*$/.exec(datestr), minutesOffset = 0, i, k; for (i = 0; (k = numericKeys[i]); ++i) { struct[k] = +struct[k] || 0; } // allow undefined days and months struct[2] = (+struct[2] || 1) - 1; struct[3] = +struct[3] || 1; if (struct[8] !== 'Z' && struct[9] !== undefined) { minutesOffset = struct[10] * 60 + struct[11]; if (struct[9] === '+') { minutesOffset = 0 - minutesOffset; } } return new Date(Date.UTC(struct[1], struct[2], struct[3], struct[4], struct[5] + minutesOffset, struct[6], struct[7])); }; 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.showControlBox = function () { var controlbox = this.chatboxes.get('controlbox'); if (!controlbox) { this.chatboxes.add({ id: 'controlbox', box_id: 'controlbox', visible: true }); if (this.connection) { this.chatboxes.get('controlbox').save(); } } else { controlbox.trigger('show'); } }; this.toggleControlBox = function () { if ($("div#controlbox").is(':visible')) { var controlbox = this.chatboxes.get('controlbox'); if (this.connection) { controlbox.destroy(); } else { controlbox.trigger('hide'); } } else { this.showControlBox(); } }; this.initStatus = function (callback) { this.xmppstatus = new this.XMPPStatus(); var id = hex_sha1('converse.xmppstatus-'+this.bare_jid); this.xmppstatus.id = id; // This appears to be necessary for backbone.localStorage this.xmppstatus.localStorage = new Backbone.LocalStorage(id); this.xmppstatus.fetch({success: callback, error: callback}); }; this.initRoster = function () { // Set up the roster this.roster = new this.RosterItems(); this.roster.localStorage = new Backbone.LocalStorage( hex_sha1('converse.rosteritems-'+converse.bare_jid)); // Register callbacks that depend on the roster this.connection.roster.registerCallback( $.proxy(this.roster.rosterHandler, this.roster), null, 'presence', null); this.connection.addHandler( $.proxy(this.roster.subscribeToSuggestedItems, this.roster), 'http://jabber.org/protocol/rosterx', 'message', null); this.connection.addHandler( $.proxy(function (presence) { this.presenceHandler(presence); return true; }, this.roster), null, 'presence', null); // No create the view which will fetch roster items from // localStorage this.rosterview = new this.RosterView({'model':this.roster}); }; this.onConnected = function () { if (this.debug) { this.connection.xmlInput = function (body) { console.log(body); }; this.connection.xmlOutput = function (body) { console.log(body); }; Strophe.log = function (level, msg) { console.log(level+' '+msg); }; Strophe.error = function (msg) { console.log('ERROR: '+msg); }; } this.bare_jid = Strophe.getBareJidFromJid(this.connection.jid); this.domain = Strophe.getDomainFromJid(this.connection.jid); this.features = new this.Features(); this.initStatus($.proxy(function () { this.initRoster(); this.chatboxes.onConnected(); this.connection.roster.get(function () {}); $(window).on("blur focus", $.proxy(function(e) { if ((this.windowState != e.type) && (e.type == 'focus')) { converse.clearMsgCounter(); } this.windowState = e.type; },this)); this.giveFeedback(__('Online Contacts')); if (this.testing) { this.callback(this); } else { this.callback(); } }, this)); }; // Backbone Models and Views // ------------------------- this.Message = Backbone.Model.extend(); this.Messages = Backbone.Collection.extend({ model: converse.Message }); this.ChatBox = Backbone.Model.extend({ initialize: function () { if (this.get('box_id') !== 'controlbox') { this.messages = new converse.Messages(); this.messages.localStorage = new Backbone.LocalStorage( hex_sha1('converse.messages'+this.get('jid')+converse.bare_jid)); this.set({ 'user_id' : Strophe.getNodeFromJid(this.get('jid')), 'box_id' : hex_sha1(this.get('jid')), 'fullname' : this.get('fullname'), 'url': this.get('url'), 'image_type': this.get('image_type'), 'image': this.get('image') }); } }, messageReceived: function (message) { var $message = $(message), body = converse.autoLink($message.children('body').text()), from = Strophe.getBareJidFromJid($message.attr('from')), composing = $message.find('composing'), delayed = $message.find('delay').length > 0, fullname = (this.get('fullname')||'').split(' ')[0], stamp, time, sender; if (!body) { if (composing.length) { this.messages.add({ fullname: fullname, sender: 'them', delayed: delayed, time: converse.toISOString(new Date()), composing: composing.length }); } } else { if (delayed) { stamp = $message.find('delay').attr('stamp'); time = stamp; } else { time = converse.toISOString(new Date()); } if (from == converse.bare_jid) { sender = 'me'; } else { sender = 'them'; } this.messages.create({ fullname: fullname, sender: sender, delayed: delayed, time: time, message: body }); } } }); this.ChatBoxView = Backbone.View.extend({ length: 200, tagName: 'div', className: 'chatbox', events: { 'click .close-chatbox-button': 'closeChat', 'keypress textarea.chat-textarea': 'keyPressed' }, message_template: _.template( '
' + '{{time}} {{username}}: ' + '{{message}}' + '
'), action_template: _.template( '
' + '{{time}} **{{username}} ' + '{{message}}' + '
'), new_day_template: _.template( '' ), appendMessage: function ($el, msg_dict) { var this_date = converse.parseISO8601(msg_dict.time), text = msg_dict.message, match = text.match(/^\/(.*?)(?: (.*))?$/), sender = msg_dict.sender, template, username; if ((match) && (match[1] === 'me')) { text = text.replace(/^\/me/, ''); template = this.action_template; username = msg_dict.fullname; } else { template = this.message_template; username = sender === 'me' && __('me') || msg_dict.fullname; } $el.find('div.chat-event').remove(); $el.append( template({ 'sender': sender, 'time': this_date.toTimeString().substring(0,5), 'message': text, 'username': username, 'extra_classes': msg_dict.delayed && 'delayed' || '' })); }, insertStatusNotification: function (message, replace) { var $chat_content = this.$el.find('.chat-content'); $chat_content.find('div.chat-event').remove().end() .append($('
').text(message)); this.scrollDown(); }, showMessage: function (message) { var time = message.get('time'), times = this.model.messages.pluck('time'), this_date = converse.parseISO8601(time), $chat_content = this.$el.find('.chat-content'), previous_message, idx, prev_date, isodate, text, match; // If this message is on a different day than the one received // prior, then indicate it on the chatbox. idx = _.indexOf(times, time)-1; if (idx >= 0) { previous_message = this.model.messages.at(idx); prev_date = converse.parseISO8601(previous_message.get('time')); isodate = new Date(this_date.getTime()); isodate.setUTCHours(0,0,0,0); isodate = converse.toISOString(isodate); if (this.isDifferentDay(prev_date, this_date)) { $chat_content.append(this.new_day_template({ isodate: isodate, datestring: this_date.toString().substring(0,15) })); } } if (message.get('composing')) { this.insertStatusNotification(__('%1$s is typing', message.get('fullname'))); return; } else { this.appendMessage($chat_content, _.clone(message.attributes)); } if ((message.get('sender') != 'me') && (converse.windowState == 'blur')) { converse.incrementMsgCounter(); } this.scrollDown(); }, isDifferentDay: function (prev_date, next_date) { return ( (next_date.getDate() != prev_date.getDate()) || (next_date.getFullYear() != prev_date.getFullYear()) || (next_date.getMonth() != prev_date.getMonth())); }, addHelpMessages: function (msgs) { var $chat_content = this.$el.find('.chat-content'), i, msgs_length = msgs.length; for (i=0; i'+msgs[i]+'')); } this.scrollDown(); }, sendMessage: function (text) { // TODO: Look in ChatPartners to see what resources we have for the recipient. // if we have one resource, we sent to only that resources, if we have multiple // we send to the bare jid. var timestamp = (new Date()).getTime(), bare_jid = this.model.get('jid'), match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/), msgs; if (match) { if (match[1] === "clear") { this.$el.find('.chat-content').empty(); this.model.messages.reset().localStorage._clear(); return; } else if (match[1] === "help") { msgs = [ '/help:'+__('Show this menu')+'', '/me:'+__('Write in the third person')+'', '/clear:'+__('Remove messages')+'' ]; this.addHelpMessages(msgs); return; } } var message = $msg({from: converse.connection.jid, to: bare_jid, type: 'chat', id: timestamp}) .c('body').t(text).up() .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}); // Forward the message, so that other connected resources are also aware of it. // TODO: Forward the message only to other connected resources (inside the browser) var forwarded = $msg({to:converse.bare_jid, type:'chat', id:timestamp}) .c('forwarded', {xmlns:'urn:xmpp:forward:0'}) .c('delay', {xmns:'urn:xmpp:delay',stamp:timestamp}).up() .cnode(message.tree()); converse.connection.send(message); converse.connection.send(forwarded); // Add the new message this.model.messages.create({ fullname: converse.xmppstatus.get('fullname')||converse.bare_jid, sender: 'me', time: converse.toISOString(new Date()), message: text }); }, keyPressed: function (ev) { var $textarea = $(ev.target), message, notify, composing; if(ev.keyCode == 13) { ev.preventDefault(); message = $textarea.val(); $textarea.val('').focus(); if (message !== '') { if (this.model.get('chatroom')) { this.sendChatRoomMessage(message); } else { this.sendMessage(message); } } this.$el.data('composing', false); } else if (!this.model.get('chatroom')) { // composing data is only for single user chat composing = this.$el.data('composing'); if (!composing) { if (ev.keyCode != 47) { // We don't send composing messages if the message // starts with forward-slash. notify = $msg({'to':this.model.get('jid'), 'type': 'chat'}) .c('composing', {'xmlns':'http://jabber.org/protocol/chatstates'}); converse.connection.send(notify); } this.$el.data('composing', true); } } }, onChange: function (item, changed) { if (_.has(item.changed, 'chat_status')) { var chat_status = item.get('chat_status'), fullname = item.get('fullname'); if (this.$el.is(':visible')) { if (chat_status === 'offline') { this.insertStatusNotification(fullname+' '+'has gone offline'); } else if (chat_status === 'away') { this.insertStatusNotification(fullname+' '+'has gone away'); } else if ((chat_status === 'dnd')) { this.insertStatusNotification(fullname+' '+'is busy'); } else if (chat_status === 'online') { this.$el.find('div.chat-event').remove(); } } } if (_.has(item.changed, 'status')) { this.showStatusMessage(item.get('status')); } if (_.has(item.changed, 'image')) { this.renderAvatar(); } // TODO check for changed fullname as well }, showStatusMessage: function (msg) { this.$el.find('p.user-custom-message').text(msg).attr('title', msg); }, closeChat: function () { if (converse.connection) { this.model.destroy(); } else { this.model.trigger('hide'); } }, updateVCard: function () { var jid = this.model.get('jid'), rosteritem = converse.roster.get(jid); if ((rosteritem) && (!rosteritem.get('vcard_updated'))) { converse.getVCard( jid, $.proxy(function (jid, fullname, image, image_type, url) { this.model.save({ 'fullname' : fullname || jid, 'url': url, 'image_type': image_type, 'image': image }); }, this), $.proxy(function (stanza) { converse.log("ChatBoxView.initialize: An error occured while fetching vcard"); }, this) ); } }, initialize: function (){ this.model.messages.on('add', this.showMessage, this); this.model.on('show', this.show, this); this.model.on('destroy', this.hide, this); this.model.on('change', this.onChange, this); this.updateVCard(); this.$el.appendTo(converse.chatboxesview.$el); this.render().show().model.messages.fetch({add: true}); if (this.model.get('status')) { this.showStatusMessage(this.model.get('status')); } }, template: _.template( '
' + '' + '' + '
{{ fullname }}
' + '
' + '

' + '

' + '
' + '
' + '