// 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 define */ (function (root, factory) { define([ "converse-core", "tpl!chatbox", "tpl!new_day", "tpl!action", "tpl!message", "tpl!help_message", "tpl!toolbar", "tpl!avatar", "tpl!spinner" ], factory); }(this, function ( converse, tpl_chatbox, tpl_new_day, tpl_action, tpl_message, tpl_help_message, tpl_toolbar, tpl_avatar, tpl_spinner ) { "use strict"; var $ = converse.env.jQuery, $msg = converse.env.$msg, Backbone = converse.env.Backbone, Strophe = converse.env.Strophe, _ = converse.env._, moment = converse.env.moment, utils = converse.env.utils; 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.__; _converse.api.settings.update({ chatview_avatar_height: 32, chatview_avatar_width: 32, show_toolbar: true, time_format: 'HH:mm', visible_toolbar_buttons: { 'emoticons': true, 'call': false, 'clear': true }, }); var onWindowStateChanged = function (data) { var state = data.state; _converse.chatboxviews.each(function (chatboxview) { chatboxview.onWindowStateChanged(state); }) }; _converse.api.listen.on('windowStateChanged', onWindowStateChanged); _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 .send-button': 'onSendButtonClicked', '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(); _converse.emit('chatBoxInitialized', this); }, render: function () { this.$el.attr('id', this.model.get('box_id')) .html(tpl_chatbox( _.extend(this.model.toJSON(), { show_toolbar: _converse.show_toolbar, show_textarea: true, show_send_button: _converse.show_send_button, title: this.model.get('fullname'), unread_msgs: __('You have unread messages'), info_close: __('Close this chat box'), label_personal_message: __('Personal message'), label_send: __('Send') } ) ) ); this.$content = this.$el.find('.chat-content'); this.renderToolbar().renderAvatar(); _converse.emit('chatBoxOpened', this); utils.refreshWebkit(); return this.showStatusMessage(); }, afterMessagesFetched: function () { this.insertIntoDOM(); this.scrollDown(); // We only start listening for the scroll event after // cached messages have been fetched this.$content.on('scroll', this.markScrolled.bind(this)); }, fetchMessages: function () { this.model.messages.fetch({ 'add': true, 'success': this.afterMessagesFetched.bind(this), 'error': this.afterMessagesFetched.bind(this), }); return this; }, insertIntoDOM: function () { /* This method gets overridden in src/converse-controlbox.js if * the controlbox plugin is active. */ var container = document.querySelector('#conversejs'); if (this.el.parentNode !== container) { container.insertBefore(this.el, container.firstChild); } 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 (_.isNull(this.el.querySelector('.spinner'))) { this.$content.prepend(tpl_spinner); } }, 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, tpl_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.scrollDown.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, $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(); var idx = msg_dates.indexOf(current_msg_date)-1; var $latest_message = this.$content.find('.chat-message[data-isodate="'+msg_dates[idx]+'"]:last'); _.flow( function ($el) { $el.insertAfter($latest_message); }, this.scrollDown.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 = tpl_action; if (attrs.sender === 'me') { fullname = _converse.xmppstatus.get('fullname') || attrs.fullname; username = _.isNil(fullname)? _converse.bare_jid: fullname; } else { username = attrs.fullname; } } else { template = tpl_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(_converse.time_format), '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').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); } _converse.emit('messageAdded', { 'message': message, 'chatbox': this.model }); }, 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); } }, onSendButtonClicked: function(ev) { /* Event handler for when a send button is clicked in a chat box textarea. */ ev.preventDefault(); var textarea = this.el.querySelector('.chat-textarea'), message = textarea.value; textarea.value = ''; textarea.focus(); if (message !== '') { this.onMessageSubmitted(message); _converse.emit('messageSend', message); } this.setChatState(_converse.ACTIVE); }, 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(); } try { this.model.destroy(); } catch (e) { _converse.log(e, Strophe.LogLevel.ERROR); } 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 || tpl_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 = $(tpl_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 (utils.isPersistableModel(this.model)) { 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; }, hideNewMessagesIndicator: function () { var new_msgs_indicator = this.el.querySelector('.new-msgs-indicator'); if (!_.isNull(new_msgs_indicator)) { new_msgs_indicator.classList.add('hidden'); } }, 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(); } if (this.model.get('auto_scrolled')) { this.model.set({ 'scrolled': false, 'auto_scrolled': false }); return; } var scrolled = true; var is_at_bottom = (this.$content.scrollTop() + this.$content.innerHeight()) >= this.$content[0].scrollHeight-10; if (is_at_bottom) { scrolled = false; this.onScrolledDown(); } utils.safeSave(this.model, {'scrolled': scrolled}); }, 150), viewUnreadMessages: function () { this.model.save('scrolled', false); this.scrollDown(); }, _scrollDown: function () { /* Inner method that gets debounced */ if (this.$content.is(':visible') && !this.model.get('scrolled')) { this.$content.scrollTop(this.$content[0].scrollHeight); this.onScrolledDown(); this.model.save({'auto_scrolled': true}); } }, onScrolledDown: function() { this.hideNewMessagesIndicator(); if (_converse.windowState !== 'hidden') { this.model.clearUnreadMsgCounter(); } _converse.emit('chatBoxScrolledDown', {'chatbox': this.model}); }, 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); } this.debouncedScrollDown.apply(this, arguments); return this; }, onWindowStateChanged: function (state) { if (this.model.get('num_unread', 0) && !this.model.newMessageWillBeHidden()) { this.model.clearUnreadMsgCounter(); } } }); } }); return converse; }));