// 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 */ (function (root, factory) { define([ "converse-core", "tpl!chatbox", "tpl!new_day", "tpl!action", "tpl!message", "tpl!toolbar", "tpl!avatar" ], factory); }(this, function ( converse, tpl_chatbox, tpl_new_day, tpl_action, tpl_message, tpl_toolbar, tpl_avatar ) { "use strict"; var $ = converse.env.jQuery, utils = converse.env.utils, Strophe = converse.env.Strophe, $msg = converse.env.$msg, _ = converse.env._, moment = converse.env.moment; var KEY = { ENTER: 13, FORWARD_SLASH: 47 }; converse.plugins.add('converse-chatview', { 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. ChatBoxViews: { onChatBoxAdded: function (item) { var _converse = this.__super__._converse; var view = this.get(item.get('id')); if (!view) { view = new _converse.ChatBoxView({model: item}); this.add(item.get('id'), view); return 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.__; // Add new HTML templates. _converse.templates.chatbox = tpl_chatbox; _converse.templates.new_day = tpl_new_day; _converse.templates.action = tpl_action; _converse.templates.message = tpl_message; _converse.templates.toolbar = tpl_toolbar; _converse.templates.avatar = tpl_avatar; this.updateSettings({ show_toolbar: true, chatview_avatar_width: 32, chatview_avatar_height: 32, visible_toolbar_buttons: { 'emoticons': true, 'call': false, 'clear': true }, }); _converse.ChatBoxView = Backbone.View.extend({ length: 200, tagName: 'div', className: 'chatbox hidden', is_chatroom: false, // Leaky abstraction from MUC events: { 'click .close-chatbox-button': 'close', 'keypress .chat-textarea': 'keyPressed', 'click .toggle-smiley': 'toggleEmoticonMenu', 'click .toggle-smiley ul li': 'insertEmoticon', 'click .toggle-clear': 'clearMessages', 'click .toggle-call': 'toggleCall', 'click .new-msgs-indicator': 'viewUnreadMessages' }, initialize: function () { 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:status', this.onStatusChanged, this); this.model.on('showHelpMessages', this.showHelpMessages, this); this.model.on('sendMessage', this.sendMessage, this); this.render().fetchMessages().insertIntoDOM(); // XXX: adding the event below to the events map above 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)); _converse.emit('chatBoxInitialized', this); }, render: function () { this.$el.attr('id', this.model.get('box_id')) .html(_converse.templates.chatbox( _.extend(this.model.toJSON(), { show_toolbar: _converse.show_toolbar, show_textarea: true, title: this.model.get('fullname'), unread_msgs: __('You have unread messages'), info_close: __('Close this chat box'), label_personal_message: __('Personal message') } ) ) ); this.$content = this.$el.find('.chat-content'); this.renderToolbar().renderAvatar(); _converse.emit('chatBoxOpened', this); utils.refreshWebkit(); return this.showStatusMessage(); }, afterMessagesFetched: function () { // Provides a hook for plugins, such as converse-mam. return; }, fetchMessages: function () { this.model.messages.fetch({ 'add': true, 'success': this.afterMessagesFetched.bind(this) }); return this; }, insertIntoDOM: function () { /* This method gets overridden in src/converse-controlbox.js if * the controlbox plugin is active. */ $('#conversejs').prepend(this.$el); return this; }, clearStatusNotification: function () { this.$content.find('div.chat-event').remove(); }, showStatusNotification: function (message, keep_old, permanent) { if (!keep_old) { this.clearStatusNotification(); } var $el = $('
').text(message); if (!permanent) { $el.addClass('chat-event'); } this.$content.append($el); 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(); } }, insertDayIndicator: function (date, prepend) { /* Appends (or prepends if "prepend" is truthy) 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'); var insert = prepend ? this.$content.prepend: this.$content.append; insert.call(this.$content, _converse.templates.new_day({ isodate: day_date.format(), datestring: day_date.format("dddd MMM Do YYYY") })); }, insertMessage: function (attrs, prepend) { /* Helper method which appends a message (or prepends if the * 2nd parameter is set to true) to the end of the chat box's * content area. * * Parameters: * (Object) attrs: An object containing the message attributes. */ var that = this; var insert = prepend ? this.$content.prepend : this.$content.append; _.flow( function ($el) { insert.call(that.$content, $el); return $el; }, this.scrollDownMessageHeight.bind(this) )(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 msg_dates, idx, $first_msg = this.$content.find('.chat-message:first'), first_msg_date = $first_msg.data('isodate'), current_msg_date = moment(attrs.time) || moment, last_msg_date = this.$content.find('.chat-message:last').data('isodate'); if (!first_msg_date) { // This is the first received message, so we insert a // date indicator before it. this.insertDayIndicator(current_msg_date); this.insertMessage(attrs); return; } if (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 this.insertDayIndicator(current_msg_date); } this.insertMessage(attrs); return; } if (current_msg_date.isBefore(first_msg_date) || current_msg_date.isSame(first_msg_date)) { // 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). this.insertMessage(attrs, 'prepend'); if (current_msg_date.isBefore(first_msg_date, 'day')) { // This message is also on a different day, so we prepend a day indicator. this.insertDayIndicator(current_msg_date, 'prepend'); } return; } // Find the correct place to position the message current_msg_date = current_msg_date.format(); msg_dates = _.map(this.$content.find('.chat-message'), function (el) { return $(el).data('isodate'); }); msg_dates.push(current_msg_date); msg_dates.sort(); idx = msg_dates.indexOf(current_msg_date)-1; _.flow( function ($el) { $el.insertAfter(this.$content.find('.chat-message[data-isodate="'+msg_dates[idx]+'"]')); return $el; }.bind(this), this.scrollDownMessageHeight.bind(this) )(this.renderMessage(attrs)); }, getExtraMessageTemplateAttributes: function () { /* Provides a hook for sending more attributes to the * message template. * * Parameters: * (Object) attrs: An object containing message attributes. */ return {}; }, getExtraMessageClasses: function (attrs) { return attrs.delayed && 'delayed' || ''; }, 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, template, username; if ((match) && (match[1] === 'me')) { text = text.replace(/^\/me/, ''); template = _converse.templates.action; if (attrs.sender === 'me') { fullname = _converse.xmppstatus.get('fullname'); username = _.isNil(fullname)? _converse.bare_jid: fullname; } else { username = attrs.fullname; } } else { template = _converse.templates.message; username = attrs.sender === 'me' && __('me') || fullname; } this.$content.find('div.chat-event').remove(); if (text.length > 8000) { text = text.substring(0, 10) + '...'; this.showStatusNotification( __("A very large message has been received."+ "This might be due to an attack meant to degrade the chat performance."+ "Output has been shortened."), true, true); } var $msg = $(template( _.extend(this.getExtraMessageTemplateAttributes(attrs), { 'msgid': attrs.msgid, 'sender': attrs.sender, 'time': msg_time.format('hh:mm'), 'isodate': msg_time.format(), 'username': username, 'extra_classes': this.getExtraMessageClasses(attrs) }) )); $msg.find('.chat-msg-content').first() .text(text) .addHyperlinks() .addEmoticons(_converse.visible_toolbar_buttons.emoticons); return $msg; }, 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), 30000); } else if (message.get('chat_state') === _converse.PAUSED) { this.showStatusNotification(message.get('fullname')+' '+__('has stopped typing')); } else if (_.includes([_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')); } }, shouldShowOnTextMessage: function () { return !this.$el.is(':visible'); }, updateNewMessageIndicators: function (message) { /* We have two indicators of new messages. The unread messages * counter, which shows the number of unread messages in * the document.title, and the "new messages" indicator in * a chat area, if it's scrolled up so that new messages * aren't visible. * * In both cases we ignore MAM messages. */ if (!message.get('archive_id')) { if (this.model.get('scrolled', true)) { this.$el.find('.new-msgs-indicator').removeClass('hidden'); } if (_converse.windowState === 'hidden' || this.model.get('scrolled', true)) { _converse.incrementMsgCounter(); } } }, handleTextMessage: function (message) { this.showMessage(_.clone(message.attributes)); if (message.get('sender') !== 'me') { this.updateNewMessageIndicators(message); } else { // We remove the "scrolled" flag so that the chat area // gets scrolled down. We always want to scroll down // when the user writes a message as opposed to when a // message is received. this.model.set('scrolled', false); } if (this.shouldShowOnTextMessage()) { this.show(); } else { this.scrollDown(); } }, handleErrorMessage: function (message) { var $message = $('[data-msgid='+message.get('msgid')+']'); if ($message.length) { $message.after($('
').text(message.get('message'))); this.scrollDown(); } }, 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 (!_.isUndefined(this.clear_status_timeout)) { window.clearTimeout(this.clear_status_timeout); delete this.clear_status_timeout; } if (message.get('type') === 'error') { this.handleErrorMessage(message); } else 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}).up() .c('no-store', {'xmlns': Strophe.NS.HINTS}).up() .c('no-permanent-store', {'xmlns': Strophe.NS.HINTS}) ); }, 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 (!_.isUndefined(this.chat_state_timeout)) { 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.value; textarea.value = ''; textarea.focus(); if (message !== '') { this.onMessageSubmitted(message); _converse.emit('messageSend', message); } this.setChatState(_converse.ACTIVE); } else { // 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); } }, 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; }, insertIntoTextArea: function (value) { var $textbox = this.$el.find('textarea.chat-textarea'); var existing = $textbox.val(); if (existing && (existing[existing.length-1] !== ' ')) { existing = existing + ' '; } $textbox.focus().val(existing+value+' '); }, insertEmoticon: function (ev) { ev.stopPropagation(); this.$el.find('.toggle-smiley ul').slideToggle(200); var $target = $(ev.target); $target = $target.is('a') ? $target : $target.children('a'); this.insertIntoTextArea($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(); } } }, onStatusChanged: function (item) { this.showStatusMessage(); _converse.emit('contactStatusMessageChanged', { 'contact': item.attributes, 'message': item.get('status') }); }, showStatusMessage: function (msg) { msg = msg || this.model.get('status'); if (_.isString(msg)) { 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) { // Immediately sending the chat state, because the // model is going to be destroyed afterwards. this.model.set('chat_state', _converse.INACTIVE); this.sendChatState(); this.model.destroy(); } this.remove(); _converse.emit('chatBoxClosed', this); return this; }, getToolbarOptions: function (options) { return _.extend(options || {}, { 'label_clear': __('Clear all messages'), '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, }); }, renderToolbar: function (toolbar, options) { if (!_converse.show_toolbar) { return; } toolbar = toolbar || _converse.templates.toolbar; options = _.extend( this.model.toJSON(), this.getToolbarOptions(options || {}) ); this.$el.find('.chat-toolbar').html(toolbar(options)); return this; }, renderAvatar: function () { if (!this.model.get('image')) { return; } var width = _converse.chatview_avatar_width; var height = _converse.chatview_avatar_height; var img_src = 'data:'+this.model.get('image_type')+';base64,'+this.model.get('image'), canvas = $(_converse.templates.avatar({ 'width': width, 'height': height })).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, width, height*(1/ratio)); } else { ctx.drawImage(img, 0,0, width, height*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 () { this.el.classList.add('hidden'); utils.refreshWebkit(); return this; }, afterShown: function (focus) { 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(); } }, _show: function (focus) { /* Inner show method that gets debounced */ if (this.$el.is(':visible') && this.$el.css('opacity') === "1") { if (focus) { this.focus(); } return; } utils.fadeIn(this.el, _.bind(this.afterShown, this, focus)); }, show: function (focus) { if (_.isUndefined(this.debouncedShow)) { /* We wrap the method in a debouncer and set it on the * instance, so that we have it debounced per instance. * Debouncing it on the class-level is too broad. */ this.debouncedShow = _.debounce(this._show, 250, {'leading': true}); } this.debouncedShow.apply(this, arguments); return this; }, markScrolled: _.debounce(function (ev) { /* Called when the chat content is scrolled up or down. * We want to record when the user has scrolled away from * the bottom, so that we don't automatically scroll away * from what the user is reading when new messages are * received. */ if (ev && ev.preventDefault) { ev.preventDefault(); } var is_at_bottom = this.$content.scrollTop() + this.$content.innerHeight() >= this.$content[0].scrollHeight-10; if (is_at_bottom) { this.model.set('scrolled', false); this.$el.find('.new-msgs-indicator').addClass('hidden'); } else { // We're not at the bottom of the chat area, so we mark // that the box is in a scrolled-up state. this.model.set('scrolled', true); } }, 150), viewUnreadMessages: function () { this.model.set('scrolled', false); this.scrollDown(); }, scrollDownMessageHeight: function ($message) { if (this.$content.is(':visible') && !this.model.get('scrolled')) { this.$content.scrollTop(this.$content.scrollTop() + $message[0].scrollHeight); } return this; }, _scrollDown: function () { /* Inner method that gets debounced */ if (this.$content.is(':visible') && !this.model.get('scrolled')) { this.$content.scrollTop(this.$content[0].scrollHeight); this.$el.find('.new-msgs-indicator').addClass('hidden'); } }, scrollDown: function () { if (_.isUndefined(this.debouncedScrollDown)) { /* We wrap the method in a debouncer and set it on the * instance, so that we have it debounced per instance. * Debouncing it on the class-level is too broad. */ this.debouncedScrollDown = _.debounce(this._scrollDown, 250, {'leading': true}); } this.debouncedScrollDown.apply(this, arguments); return this; } }); } }); return converse; }));