converse-chatview.js 40.4 KB
Newer Older
1 2 3
// Converse.js (A browser based XMPP chat client)
// http://conversejs.org
//
JC Brand's avatar
JC Brand committed
4
// Copyright (c) 2012-2017, Jan-Carel Brand <jc@opkode.com>
5 6
// Licensed under the Mozilla Public License (MPLv2)
//
7
/*global define */
8 9

(function (root, factory) {
JC Brand's avatar
JC Brand committed
10
    define([
11
            "converse-core",
12 13 14 15
            "tpl!chatbox",
            "tpl!new_day",
            "tpl!action",
            "tpl!message",
16
            "tpl!help_message",
17 18
            "tpl!toolbar",
            "tpl!avatar"
19 20
    ], factory);
}(this, function (
21
            converse,
22 23 24 25
            tpl_chatbox,
            tpl_new_day,
            tpl_action,
            tpl_message,
26
            tpl_help_message,
27 28
            tpl_toolbar,
            tpl_avatar
29
    ) {
30
    "use strict";
31 32
    var $ = converse.env.jQuery,
        $msg = converse.env.$msg,
33 34
        Backbone = converse.env.Backbone,
        Strophe = converse.env.Strophe,
35
        _ = converse.env._,
36 37
        moment = converse.env.moment,
        utils = converse.env.utils;
38

39 40 41 42 43 44
    var KEY = {
        ENTER: 13,
        FORWARD_SLASH: 47
    };


45
    converse.plugins.add('converse-chatview', {
46 47 48 49 50 51 52 53 54 55

        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) {
56
                    var _converse = this.__super__._converse;
57
                    var view = this.get(item.get('id'));
58
                    if (!view) {
59
                        view = new _converse.ChatBoxView({model: item});
60
                        this.add(item.get('id'), view);
61
                        return view;
62
                    } else {
JC Brand's avatar
JC Brand committed
63
                        return this.__super__.onChatBoxAdded.apply(this, arguments);
64 65 66 67 68 69 70 71 72 73
                    }
                }
            }
        },


        initialize: function () {
            /* The initialize function gets called as soon as the plugin is
             * loaded by converse.js's plugin machinery.
             */
74 75 76
            var _converse = this._converse,
                __ = _converse.__;

77
            this.updateSettings({
78
                chatview_avatar_height: 32,
79 80 81
                chatview_avatar_width: 32,
                show_toolbar: true,
                time_format: 'HH:mm',
82 83 84
                visible_toolbar_buttons: {
                    'emoticons': true,
                    'call': false,
85
                    'clear': true
86
                },
87 88
            });

89 90 91 92 93 94 95 96 97
            var onWindowStateChanged = function (data) {
                var state = data.state;
                _converse.chatboxviews.each(function (chatboxview) {
                    chatboxview.onWindowStateChanged(state);
                })
            };

            _converse.api.listen.on('windowStateChanged', onWindowStateChanged);

98
            _converse.ChatBoxView = Backbone.View.extend({
99 100
                length: 200,
                tagName: 'div',
101
                className: 'chatbox hidden',
102
                is_chatroom: false,  // Leaky abstraction from MUC
103 104 105

                events: {
                    'click .close-chatbox-button': 'close',
106
                    'keypress .chat-textarea': 'keyPressed',
107
                    'click .send-button': 'onSendButtonClicked',
108 109 110
                    'click .toggle-smiley': 'toggleEmoticonMenu',
                    'click .toggle-smiley ul li': 'insertEmoticon',
                    'click .toggle-clear': 'clearMessages',
111 112
                    'click .toggle-call': 'toggleCall',
                    'click .new-msgs-indicator': 'viewUnreadMessages'
113 114 115 116 117 118 119 120 121 122 123 124 125
                },

                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);
126
                    this.render().fetchMessages();
127
                    _converse.emit('chatBoxInitialized', this);
128 129 130 131
                },

                render: function () {
                    this.$el.attr('id', this.model.get('box_id'))
132
                        .html(tpl_chatbox(
133
                                _.extend(this.model.toJSON(), {
134
                                        show_toolbar: _converse.show_toolbar,
135
                                        show_textarea: true,
136
                                        show_send_button: _converse.show_send_button,
137
                                        title: this.model.get('fullname'),
138
                                        unread_msgs: __('You have unread messages'),
139
                                        info_close: __('Close this chat box'),
140 141
                                        label_personal_message: __('Personal message'),
                                        label_send: __('Send')
142 143 144 145 146 147
                                    }
                                )
                            )
                        );
                    this.$content = this.$el.find('.chat-content');
                    this.renderToolbar().renderAvatar();
148
                    _converse.emit('chatBoxOpened', this);
149
                    utils.refreshWebkit();
150 151 152
                    return this.showStatusMessage();
                },

153
                afterMessagesFetched: function () {
154
                    this.insertIntoDOM();
JC Brand's avatar
JC Brand committed
155
                    this.scrollDown();
156 157 158
                    // We only start listening for the scroll event after
                    // cached messages have been fetched
                    this.$content.on('scroll', this.markScrolled.bind(this));
159 160 161 162
                },

                fetchMessages: function () {
                    this.model.messages.fetch({
163
                        'add': true,
164 165
                        'success': this.afterMessagesFetched.bind(this),
                        'error': this.afterMessagesFetched.bind(this),
166 167 168 169
                    });
                    return this;
                },

170
                insertIntoDOM: function () {
171
                    /* This method gets overridden in src/converse-controlbox.js if
JC Brand's avatar
JC Brand committed
172 173
                     * the controlbox plugin is active.
                     */
JC Brand's avatar
JC Brand committed
174 175 176 177
                    var container = document.querySelector('#conversejs');
                    if (this.el.parentNode !== container) {
                        container.insertBefore(this.el, container.firstChild);
                    }
178 179 180 181 182 183 184
                    return this;
                },

                clearStatusNotification: function () {
                    this.$content.find('div.chat-event').remove();
                },

185
                showStatusNotification: function (message, keep_old, permanent) {
186 187 188
                    if (!keep_old) {
                        this.clearStatusNotification();
                    }
189 190 191 192 193
                    var $el = $('<div class="chat-info"></div>').text(message);
                    if (!permanent) {
                        $el.addClass('chat-event');
                    }
                    this.$content.append($el);
194
                    this.scrollDown();
195 196 197
                },

                addSpinner: function () {
198
                    if (_.isNull(this.el.querySelector('.spinner'))) {
199 200 201 202 203 204 205 206 207 208
                        this.$content.prepend('<span class="spinner"/>');
                    }
                },

                clearSpinner: function () {
                    if (this.$content.children(':first').is('span.spinner')) {
                        this.$content.children(':first').remove();
                    }
                },

209 210 211 212
                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.
JC Brand's avatar
JC Brand committed
213 214 215 216
                     *
                     * Parameters:
                     *  (String) date - An ISO8601 date string.
                     */
217
                    var day_date = moment(date).startOf('day');
218
                    var insert = prepend ? this.$content.prepend: this.$content.append;
219
                    insert.call(this.$content, tpl_new_day({
220 221 222 223 224
                        isodate: day_date.format(),
                        datestring: day_date.format("dddd MMM Do YYYY")
                    }));
                },

225 226 227 228
                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.
JC Brand's avatar
JC Brand committed
229 230 231 232
                     *
                     * Parameters:
                     *  (Object) attrs: An object containing the message attributes.
                     */
233
                    var that = this;
234
                    var insert = prepend ? this.$content.prepend : this.$content.append;
235
                    _.flow(
236
                        function ($el) {
237
                            insert.call(that.$content, $el);
238
                            return $el;
239
                        },
240
                        this.scrollDown.bind(this)
241 242 243 244 245
                    )(this.renderMessage(attrs));
                },

                showMessage: function (attrs) {
                    /* Inserts a chat message into the content area of the chat box.
JC Brand's avatar
JC Brand committed
246 247 248 249 250 251 252
                     * 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:
253 254
                     *  (Object) attrs: An object containing the message
                     *      attributes.
JC Brand's avatar
JC Brand committed
255
                     */
256
                    var msg_dates,
257
                        $first_msg = this.$content.find('.chat-message:first'),
258
                        first_msg_date = $first_msg.data('isodate'),
259
                        current_msg_date = moment(attrs.time) || moment,
260
                        last_msg_date = this.$content.find('.chat-message:last').data('isodate');
261

262
                    if (!first_msg_date) {
263 264 265 266
                        // This is the first received message, so we insert a
                        // date indicator before it.
                        this.insertDayIndicator(current_msg_date);
                        this.insertMessage(attrs);
267 268
                        return;
                    }
269 270
                    if (current_msg_date.isAfter(last_msg_date) ||
                            current_msg_date.isSame(last_msg_date)) {
271 272 273
                        // The new message is after the last message
                        if (current_msg_date.isAfter(last_msg_date, 'day')) {
                            // Append a new day indicator
274
                            this.insertDayIndicator(current_msg_date);
275
                        }
276
                        this.insertMessage(attrs);
277 278
                        return;
                    }
279 280
                    if (current_msg_date.isBefore(first_msg_date) ||
                            current_msg_date.isSame(first_msg_date)) {
281 282
                        // The message is before the first, but on the same day.
                        // We need to prepend the message immediately before the
283 284
                        // first message (so that it'll still be after the day
                        // indicator).
285
                        this.insertMessage(attrs, 'prepend');
286
                        if (current_msg_date.isBefore(first_msg_date, 'day')) {
287 288
                            // This message is also on a different day, so
                            // we prepend a day indicator.
289
                            this.insertDayIndicator(current_msg_date, 'prepend');
290
                        }
291
                        return;
292
                    }
293 294
                    // Find the correct place to position the message
                    current_msg_date = current_msg_date.format();
295
                    msg_dates = _.map(this.$content.find('.chat-message'), function (el) {
296 297 298 299
                        return $(el).data('isodate');
                    });
                    msg_dates.push(current_msg_date);
                    msg_dates.sort();
300 301
                    var idx = msg_dates.indexOf(current_msg_date)-1;
                    var $latest_message = this.$content.find('.chat-message[data-isodate="'+msg_dates[idx]+'"]:last');
302 303
                    _.flow(
                        function ($el) {
304 305
                            $el.insertAfter($latest_message);
                        },
306
                        this.scrollDown.bind(this)
307 308 309 310 311 312 313 314 315 316
                    )(this.renderMessage(attrs));
                },

                getExtraMessageTemplateAttributes: function () {
                    /* Provides a hook for sending more attributes to the
                     * message template.
                     *
                     * Parameters:
                     *  (Object) attrs: An object containing message attributes.
                     */
317 318 319
                    return {};
                },

JC Brand's avatar
JC Brand committed
320 321 322 323
                getExtraMessageClasses: function (attrs) {
                    return attrs.delayed && 'delayed' || '';
                },

324 325
                renderMessage: function (attrs) {
                    /* Renders a chat message based on the passed in attributes.
JC Brand's avatar
JC Brand committed
326 327 328 329 330 331 332
                     *
                     * Parameters:
                     *  (Object) attrs: An object containing the message attributes.
                     *
                     *  Returns:
                     *      The DOM element representing the message.
                     */
333 334 335 336 337 338 339 340
                    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/, '');
341
                        template = tpl_action;
342
                        if (attrs.sender === 'me') {
343
                            fullname = _converse.xmppstatus.get('fullname') || attrs.fullname;
344 345 346 347
                            username = _.isNil(fullname)? _converse.bare_jid: fullname;
                        } else {
                            username = attrs.fullname;
                        }
348
                    } else  {
349
                        template = tpl_message;
350 351 352 353
                        username = attrs.sender === 'me' && __('me') || fullname;
                    }
                    this.$content.find('div.chat-event').remove();

354 355 356 357 358 359 360 361
                    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);
                    }
362 363 364 365
                    var $msg = $(template(
                        _.extend(this.getExtraMessageTemplateAttributes(attrs), {
                            'msgid': attrs.msgid,
                            'sender': attrs.sender,
366
                            'time': msg_time.format(_converse.time_format),
367 368
                            'isodate': msg_time.format(),
                            'username': username,
JC Brand's avatar
JC Brand committed
369
                            'extra_classes': this.getExtraMessageClasses(attrs)
370 371 372 373 374
                        })
                    ));
                    $msg.find('.chat-msg-content').first()
                        .text(text)
                        .addHyperlinks()
375
                        .addEmoticons(_converse.visible_toolbar_buttons.emoticons);
376
                    return $msg;
377 378 379 380 381
                },

                showHelpMessages: function (msgs, type, spinner) {
                    var i, msgs_length = msgs.length;
                    for (i=0; i<msgs_length; i++) {
382 383 384 385
                        this.$content.append($(tpl_help_message({
                            'type': type||'info',
                            'message': msgs[i]
                        })));
386 387 388 389 390 391 392 393 394 395
                    }
                    if (spinner === true) {
                        this.$content.append('<span class="spinner"/>');
                    } else if (spinner === false) {
                        this.$content.find('span.spinner').remove();
                    }
                    return this.scrollDown();
                },

                handleChatStateMessage: function (message) {
396
                    if (message.get('chat_state') === _converse.COMPOSING) {
397
                        if (message.get('sender') === 'me') {
398
                            this.showStatusNotification(__('Typing from another device'));
399
                        } else {
400 401
                            this.showStatusNotification(message.get('fullname')+' '+__('is typing'));
                        }
402
                        this.clear_status_timeout = window.setTimeout(this.clearStatusNotification.bind(this), 30000);
403
                    } else if (message.get('chat_state') === _converse.PAUSED) {
404
                        if (message.get('sender') === 'me') {
405
                            this.showStatusNotification(__('Stopped typing on the other device'));
406
                        } else {
407 408
                            this.showStatusNotification(message.get('fullname')+' '+__('has stopped typing'));
                        }
409
                    } else if (_.includes([_converse.INACTIVE, _converse.ACTIVE], message.get('chat_state'))) {
410
                        this.$content.find('div.chat-event').remove();
411
                    } else if (message.get('chat_state') === _converse.GONE) {
412 413 414 415
                        this.showStatusNotification(message.get('fullname')+' '+__('has gone away'));
                    }
                },

416 417 418 419
                shouldShowOnTextMessage: function () {
                    return !this.$el.is(':visible');
                },

420 421
                handleTextMessage: function (message) {
                    this.showMessage(_.clone(message.attributes));
422
                    if (utils.isNewMessage(message) && message.get('sender') === 'me') {
423 424 425 426 427
                        // 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);
428 429 430 431
                    } else {
                        if (utils.isNewMessage(message) && this.model.get('scrolled', true)) {
                            this.$el.find('.new-msgs-indicator').removeClass('hidden');
                        }
432
                    }
433
                    if (this.shouldShowOnTextMessage()) {
434
                        this.show();
435 436
                    } else {
                        this.scrollDown();
437 438 439
                    }
                },

440 441 442
                handleErrorMessage: function (message) {
                    var $message = $('[data-msgid='+message.get('msgid')+']');
                    if ($message.length) {
443
                        $message.after($('<div class="chat-info chat-error"></div>').text(message.get('message')));
444 445 446 447
                        this.scrollDown();
                    }
                },

448 449
                onMessageAdded: function (message) {
                    /* Handler that gets called when a new message object is created.
JC Brand's avatar
JC Brand committed
450 451 452 453
                     *
                     * Parameters:
                     *    (Object) message - The message Backbone object that was added.
                     */
454
                    if (!_.isUndefined(this.clear_status_timeout)) {
455 456 457
                        window.clearTimeout(this.clear_status_timeout);
                        delete this.clear_status_timeout;
                    }
458 459 460
                    if (message.get('type') === 'error') {
                        this.handleErrorMessage(message);
                    } else if (!message.get('message')) {
461 462 463 464
                        this.handleChatStateMessage(message);
                    } else {
                        this.handleTextMessage(message);
                    }
465 466 467 468
                    _converse.emit('messageAdded', {
                        'message': message,
                        'chatbox': this.model
                    });
469 470 471 472
                },

                createMessageStanza: function (message) {
                    return $msg({
473
                                from: _converse.connection.jid,
474 475 476 477
                                to: this.model.get('jid'),
                                type: 'chat',
                                id: message.get('msgid')
                        }).c('body').t(message.get('message')).up()
478
                            .c(_converse.ACTIVE, {'xmlns': Strophe.NS.CHATSTATES}).up();
479 480 481 482
                },

                sendMessage: function (message) {
                    /* Responsible for sending off a text message.
JC Brand's avatar
JC Brand committed
483 484 485 486
                     *
                     *  Parameters:
                     *    (Message) message - The chat message
                     */
487 488 489
                    // TODO: We might want to send to specfic resources.
                    // Especially in the OTR case.
                    var messageStanza = this.createMessageStanza(message);
490 491
                    _converse.connection.send(messageStanza);
                    if (_converse.forward_messages) {
492
                        // Forward the message, so that other connected resources are also aware of it.
493 494
                        _converse.connection.send(
                            $msg({ to: _converse.bare_jid, type: 'chat', id: message.get('msgid') })
495 496 497 498 499 500 501 502 503
                            .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
JC Brand's avatar
JC Brand committed
504 505 506 507 508
                     * and then pressed enter in a chat box.
                     *
                     *  Parameters:
                     *    (string) text - The chat message text.
                     */
509
                    if (!_converse.connection.authenticated) {
510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530
                        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 = [
                                '<strong>/help</strong>:'+__('Show this menu')+'',
                                '<strong>/me</strong>:'+__('Write in the third person')+'',
                                '<strong>/clear</strong>:'+__('Remove messages')+''
                                ];
                            this.showHelpMessages(msgs);
                            return;
                        }
                    }
531 532
                    var fullname = _converse.xmppstatus.get('fullname');
                    fullname = _.isEmpty(fullname)? _converse.bare_jid: fullname;
533 534 535 536 537 538 539 540 541 542 543
                    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
JC Brand's avatar
JC Brand committed
544 545 546
                     * as taken from the 'chat_state' attribute of the chat box.
                     * See XEP-0085 Chat State Notifications.
                     */
547
                    _converse.connection.send(
548
                        $msg({'to':this.model.get('jid'), 'type': 'chat'})
549 550 551
                            .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})
552 553 554 555 556
                    );
                },

                setChatState: function (state, no_save) {
                    /* Mutator for setting the chat state of this chat session.
JC Brand's avatar
JC Brand committed
557 558 559 560 561 562 563 564 565 566
                     * 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.
                     */
567
                    if (!_.isUndefined(this.chat_state_timeout)) {
568 569 570
                        window.clearTimeout(this.chat_state_timeout);
                        delete this.chat_state_timeout;
                    }
571
                    if (state === _converse.COMPOSING) {
572
                        this.chat_state_timeout = window.setTimeout(
573 574
                                this.setChatState.bind(this), _converse.TIMEOUTS.PAUSED, _converse.PAUSED);
                    } else if (state === _converse.PAUSED) {
575
                        this.chat_state_timeout = window.setTimeout(
576
                                this.setChatState.bind(this), _converse.TIMEOUTS.INACTIVE, _converse.INACTIVE);
577 578 579 580 581 582 583 584 585
                    }
                    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.
JC Brand's avatar
JC Brand committed
586
                     */
587
                    var textarea = ev.target, message;
588 589
                    if (ev.keyCode === KEY.ENTER) {
                        ev.preventDefault();
590 591 592
                        message = textarea.value;
                        textarea.value = '';
                        textarea.focus();
593
                        if (message !== '') {
594
                            this.onMessageSubmitted(message);
595
                            _converse.emit('messageSend', message);
596
                        }
597
                        this.setChatState(_converse.ACTIVE);
598
                    } else {
599 600
                        // Set chat state to composing if keyCode is not a forward-slash
                        // (which would imply an internal command and not a message).
601
                        this.setChatState(_converse.COMPOSING, ev.keyCode === KEY.FORWARD_SLASH);
602 603 604
                    }
                },

605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620
                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);
                },

621 622 623 624 625 626 627 628 629 630 631
                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;
                },

632 633 634 635 636 637 638 639 640
                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+' ');
                },

641 642 643 644 645
                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');
646
                    this.insertIntoTextArea($target.data('emoticon'));
647 648 649 650 651 652 653 654 655
                },

                toggleEmoticonMenu: function (ev) {
                    ev.stopPropagation();
                    this.$el.find('.toggle-smiley ul').slideToggle(200);
                },

                toggleCall: function (ev) {
                    ev.stopPropagation();
656 657
                    _converse.emit('callButtonClicked', {
                        connection: _converse.connection,
658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680
                        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();
681
                    _converse.emit('contactStatusMessageChanged', {
682 683 684 685 686 687 688
                        'contact': item.attributes,
                        'message': item.get('status')
                    });
                },

                showStatusMessage: function (msg) {
                    msg = msg || this.model.get('status');
689
                    if (_.isString(msg)) {
690 691 692 693 694 695 696
                        this.$el.find('p.user-custom-message').text(msg).attr('title', msg);
                    }
                    return this;
                },

                close: function (ev) {
                    if (ev && ev.preventDefault) { ev.preventDefault(); }
697
                    if (_converse.connection.connected) {
698 699
                        // Immediately sending the chat state, because the
                        // model is going to be destroyed afterwards.
700
                        this.model.set('chat_state', _converse.INACTIVE);
701
                        this.sendChatState();
702
                    }
JC Brand's avatar
JC Brand committed
703 704 705
                    try {
                        this.model.destroy();
                    } catch (e) {
706
                        _converse.log(e);
JC Brand's avatar
JC Brand committed
707
                    }
708
                    this.remove();
709
                    _converse.emit('chatBoxClosed', this);
710 711 712
                    return this;
                },

713 714 715 716 717
                getToolbarOptions: function (options) {
                    return _.extend(options || {}, {
                        'label_clear': __('Clear all messages'),
                        'label_insert_smiley': __('Insert a smiley'),
                        'label_start_call': __('Start a call'),
718 719 720
                        'show_call_button': _converse.visible_toolbar_buttons.call,
                        'show_clear_button': _converse.visible_toolbar_buttons.clear,
                        'show_emoticons': _converse.visible_toolbar_buttons.emoticons,
721
                    });
722 723 724
                },

                renderToolbar: function (toolbar, options) {
725
                    if (!_converse.show_toolbar) { return; }
726
                    toolbar = toolbar || tpl_toolbar;
727 728 729 730 731
                    options = _.extend(
                        this.model.toJSON(),
                        this.getToolbarOptions(options || {})
                    );
                    this.$el.find('.chat-toolbar').html(toolbar(options));
732 733 734 735 736 737 738
                    return this;
                },

                renderAvatar: function () {
                    if (!this.model.get('image')) {
                        return;
                    }
739 740
                    var width = _converse.chatview_avatar_width;
                    var height = _converse.chatview_avatar_height;
741
                    var img_src = 'data:'+this.model.get('image_type')+';base64,'+this.model.get('image'),
742
                        canvas = $(tpl_avatar({
743 744 745
                            'width': width,
                            'height': height
                        })).get(0);
746 747 748 749 750 751 752 753 754

                    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) {
755
                            ctx.drawImage(img, 0,0, width, height*(1/ratio));
756
                        } else {
757
                            ctx.drawImage(img, 0,0, width, height*ratio);
758 759 760 761 762 763 764 765 766 767
                        }

                    };
                    img.src = img_src;
                    this.$el.find('.chat-title').before(canvas);
                    return this;
                },

                focus: function () {
                    this.$el.find('.chat-textarea').focus();
768
                    _converse.emit('chatBoxFocused', this);
769 770 771 772
                    return this;
                },

                hide: function () {
773
                    this.el.classList.add('hidden');
774
                    utils.refreshWebkit();
775 776 777
                    return this;
                },

778
                afterShown: function (focus) {
779
                    if (this.model.collection.browserStorage) {
JC Brand's avatar
JC Brand committed
780 781 782 783
                        // Without a connection, we haven't yet initialized
                        // localstorage
                        this.model.save();
                    }
784
                    this.setChatState(_converse.ACTIVE);
JC Brand's avatar
JC Brand committed
785 786 787 788 789 790
                    this.scrollDown();
                    if (focus) {
                        this.focus();
                    }
                },

791 792 793 794 795 796
                _show: function (focus) {
                    /* Inner show method that gets debounced */
                    if (this.$el.is(':visible') && this.$el.css('opacity') === "1") {
                        if (focus) { this.focus(); }
                        return;
                    }
797
                    utils.fadeIn(this.el, _.bind(this.afterShown, this, focus));
798 799
                },

800
                show: function (focus) {
801
                    if (_.isUndefined(this.debouncedShow)) {
802
                        /* We wrap the method in a debouncer and set it on the
JC Brand's avatar
JC Brand committed
803 804 805
                         * instance, so that we have it debounced per instance.
                         * Debouncing it on the class-level is too broad.
                         */
806
                        this.debouncedShow = _.debounce(this._show, 250, {'leading': true});
807 808 809 810 811
                    }
                    this.debouncedShow.apply(this, arguments);
                    return this;
                },

812 813 814 815 816 817 818
                hideNewMessagesIndicator: function () {
                    var new_msgs_indicator = this.el.querySelector('.new-msgs-indicator');
                    if (!_.isNull(new_msgs_indicator)) {
                        new_msgs_indicator.classList.add('hidden');
                    }
                },

819 820 821 822 823 824 825 826
                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(); }
827 828 829 830 831 832 833
                    if (this.model.get('auto_scrolled')) {
                        this.model.set({
                            'scrolled': false,
                            'auto_scrolled': false
                        });
                        return;
                    }
834 835 836
                    var is_at_bottom =
                        (this.$content.scrollTop() + this.$content.innerHeight()) >=
                            this.$content[0].scrollHeight-10;
837
                    if (is_at_bottom) {
838
                        this.model.save('scrolled', false);
839
                        this.onScrolledDown();
840 841 842
                    } else {
                        // We're not at the bottom of the chat area, so we mark
                        // that the box is in a scrolled-up state.
843
                        this.model.save('scrolled', true);
844
                    }
845
                }, 150),
846

847
                viewUnreadMessages: function () {
848
                    this.model.save('scrolled', false);
849 850 851
                    this.scrollDown();
                },

JC Brand's avatar
JC Brand committed
852 853
                _scrollDown: function () {
                    /* Inner method that gets debounced */
854
                    if (this.$content.is(':visible') && !this.model.get('scrolled')) {
855
                        this.$content.scrollTop(this.$content[0].scrollHeight);
856
                        this.onScrolledDown();
857
                        this.model.save({'auto_scrolled': true});
858
                    }
JC Brand's avatar
JC Brand committed
859 860
                },

861 862 863 864 865 866 867 868
                onScrolledDown: function() {
                    this.hideNewMessagesIndicator();
                    if (_converse.windowState !== 'hidden') {
                        this.model.clearUnreadMsgCounter();
                    }
                    _converse.emit('chatBoxScrolledDown', {'chatbox': this.model});
                },

JC Brand's avatar
JC Brand committed
869 870 871 872 873 874
                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.
                         */
JC Brand's avatar
JC Brand committed
875
                        this.debouncedScrollDown = _.debounce(this._scrollDown, 250);
JC Brand's avatar
JC Brand committed
876 877
                    }
                    this.debouncedScrollDown.apply(this, arguments);
878
                    return this;
879 880 881
                },

                onWindowStateChanged: function (state) {
882
                    if (this.model.get('num_unread', 0) && !this.model.newMessageWillBeHidden()) {
883 884
                        this.model.clearUnreadMsgCounter();
                    }
885 886 887 888
                }
            });
        }
    });
889 890

    return converse;
891
}));