trailofbits/tubertc

View on GitHub
public/js/chat.js

Summary

Maintainability
F
3 days
Test Coverage
/**
 * @file Defines the chat panel UI elements.
 *
 * @requires Handlebars.js
 * @requires module:js/chat-cmds
 * @requires module:js/sound
 * @requires module:js/error
 */

'use strict';

// jQuery selectors
var _chatTextEntry = $('#chatTextEntry');
var _chatControlPane = $('.chatControlPane');
var _chatHistoryPane = $('.chatHistoryPane');

/**
 * Resizes the chat panes.
 *
 * @returns {undefined} undefined
 * @public
 */
var resizeChatPanes = function() {
    // outerHeight() includes padding but not margin, margin-top is 2px (see chat.css)
    var chatTextEntryHeight = _chatTextEntry.outerHeight() + 2;
    var chatControlPaneHeight = chatTextEntryHeight;
    _chatControlPane.css('height', chatControlPaneHeight + 'px');

    var historyPaneHeight = $('.sidePanelContent').height() - _chatControlPane.height() - 10;
    _chatHistoryPane.css('height', historyPaneHeight + 'px');
};

/**
 * Generates a random color.
 *
 * @returns {String} A string representing
 * a random HSL color.
 * @public
 */
var getRandomColor = function() {
    var h = Math.floor((Math.random() * 360) / 24) * 24;
    return 'hsl(' + h + ', 100%, 25%)';
};

/**
 * Creates a new chat instance.
 *
 * @param {String} roomName - The room name.
 * @class
 */
var Chat = function(roomName) {
    // @todo (input): make sure that roomName is sanitized

    // An Object storing mappings of peerId : String => userName : String
    var _peerIdMap = {};

    var _audio = new SoundClip($('#chatAlertSound')[0]);

    // Stores the last peerId to send a message (for Adium-like IM interface)
    var _lastPeerIdMessage = null;

    // Stores the last command for quick callback (for when up arrow is clicked on empty text entry bar
    var _lastCommand = null;

    // Stores the function used to send peer messages
    this.sendMessage = null;

    // Default color palette for chatTextEntry. 'Idle' means that the text entry element does
    // not contain any text worth saving.
    this.kIdleTextColor = '#c0c0c0';
    this.kActiveTextColor = _chatTextEntry.css('color');

    // Default time-to-live for notifications (in seconds)
    this.kDefaultNotificationTimeout = 3;

    /**
     * Appends a line to the chat history.
     *
     * @param {String} content - Chat content.
     * @returns {undefined} undefined
     * @private
     */
    this._appendLine = function(content) {
        _chatHistoryPane
            .append(content)
            .stop(true, false)
            .animate({
                scrollTop: _chatHistoryPane.prop('scrollHeight')
            }, 'slow');
    };

    // Stores mappings of peerId : String -> hsvColor : String pairs
    this.peerColorMap = {};

    // Stores a list of hsvColor strings
    this.colorsUsed = [];

    this.roomName = roomName;
    this.peerId = null;
    this.userName = null;

    this.showNotifications = false;

    this.notificationTmpl = Handlebars.compile(
        '<div class="chatNotification">' +
        '<span class="chatRoomName controlRoomName">[{{room}}]</span>: {{msg}}' +
        '</div>'
    );

    this.userEnteredTmpl = Handlebars.compile(
        '<div class="chatNotification">' +
        '<span class="chatUsername tooltip" style="color:{{color}}" title="{{id}}">{{user}}</span> has entered <span class="chatRoomName">{{room}}</span>.' +
        '</div>'
    );

    this.userLeftTmpl = Handlebars.compile(
        '<div class="chatNotification">' +
        '<span class="chatUsername tooltip" style="color:{{color}}" title="{{id}}">{{user}}</span> has left <span class="chatRoomName">{{room}}</span>.' +
        '</div>'
    );

    this.messageTmpl = Handlebars.compile(
        '<div class="chatMessageHeader">' +
        '<span class="chatUsername tooltip" style="color:{{color}}" title="{{id}}">{{user}}</span>' +
        '<span class="chatTimestamp">{{time}}</span>' +
        '</div> ' +
        '<div class="chatMessage">{{msg}}</div>'
    );

    this.msgContinueTmpl = Handlebars.compile(
        '<div class="chatMessage">{{msg}}</div>'
    );

    /**
     * Returns a timestamp of the hours, minutes, and seconds.
     *
     * @returns {String} the timestamp.
     * @public
     */
    var getTimeStamp = function() {
        var date = new Date();
        var hours = '' + date.getHours();
        if (hours.length === 1) {
            hours = '0' + hours;
        }
        var min = '' + date.getMinutes();
        if (min.length === 1) {
            min = '0' + min;
        }
        var secs = '' + date.getSeconds();
        if (secs.length === 1) {
            secs = '0' + secs;
        }
        return hours + ':' + min + ':' + secs;
    };

    /**
     * Generates a unique color.
     *
     * @param {String} peerId - The peer for whom
     * we want to apply a unique color.
     * @returns {String} A string representing
     * a random HSL color.
     * @private
     */
    this._generateUniqueColor = function(peerId) {
        var tries = 5;
        var hsvColor = getRandomColor();

        // Generate a random HSV value til an unique one is found or we reached 5 tries
        while (this.colorsUsed.indexOf(hsvColor) > -1 && tries > 0) {
            hsvColor = getRandomColor();
            tries--;
        }

        if (tries === 0) {
            ErrorMetric.log('Could not generate random color for user ' + peerId);

            // Return black in the case of an error
            return 'hsl(0, 0%, 0%, 1)';
        }

        this.peerColorMap[peerId] = hsvColor;
        this.colorsUsed.push(hsvColor);

        return hsvColor;
    };

    /**
     * Generates a chat notification.
     *
     * @param {String} title - Notification title.
     * @param {String} msg - Notification message/content.
     * @param {Number} ttl - Number of seconds to wait
     * before closing the notification.
     * @returns {undefined} undefined
     * @private
     */
    this._notify = function(title, msg, ttl) {
        if (this.showNotifications) {
            var notification = new Notification(title, {
                icon: '/images/tubertc_icon.png',
                body: msg
            });

            _audio.play();

            // Use default time-to-live if none is provided.
            // (time-to-live is in seconds)
            if (ttl === undefined) {
                ttl = this.kDefaultNotificationTimeout;
            }

            setInterval(function() {
                notification.close();
            }, ttl * 1000);
        }
    };

    /**
     * Maps peerIds to usernames.
     *
     * @returns {Object} A mapping of peerId => username,
     * _i.e._ { peerId : string => userName : string }
     * @public
     */
    this.getPeerIdToUserNameMap = function() {
        return _peerIdMap;
    };

    /**
     * Handles notification when a user enters the room.
     *
     * @param {String} peerId - The RTC peer ID of the
     * user that entered the chatroom.
     * @param {String} userName - The name of the user
     * that entered the chatroom.
     * @returns {Object} The current Chat instance.
     * @public
     */
    this.userEntered = function(peerId, userName) {
        var _this = this;

        // @todo Should we give self a special color?
        if (this.peerId !== null) {
            var hsvColor = this._generateUniqueColor(peerId);

            // @todo (input): both userName and roomName come from user-supplied values, are they safe?
            var content = this.userEnteredTmpl({
                color: hsvColor,
                user: userName,
                id: peerId,
                room: _this.roomName
            });

            _peerIdMap[peerId] = userName;

            // Do not show self events
            if (this.peerId !== peerId) {
                this._notify('Room Status', userName + ' (' + peerId + ') has entered the room');
            }

            this._appendLine(content);
        } else {
            ErrorMetric.log('Chat.userEntered => userEnter invoked without Chat.userName');
        }

        return this;
    };

    /**
     * Removes peerId from the chat. We don't use usernames
     * because they can collide. Peer IDs are much more unique
     * and map to user names.
     *
     * @param {String} peerId - The peerId of the user that
     * left the chatroom.
     * @returns {Object} The current Chat instance.
     * @public
     */
    this.userLeft = function(peerId) {
        var _this = this;
        if (this.peerId !== null) {
            var userName = _peerIdMap[peerId];

            var hsvColor = this.peerColorMap[peerId];
            if (hsvColor !== undefined) {
                // @todo (input): roomName and userName are from tainted user input
                var content = this.userLeftTmpl({
                    color: hsvColor,
                    user: userName,
                    id: peerId,
                    room: _this.roomName
                });

                // Do not show self events
                if (this.peerId !== peerId) {
                    this._notify('Room Status', userName + ' (' + peerId + ') has left the room');
                }

                this._appendLine(content);

                delete this.peerColorMap[peerId];

                var idx = this.colorsUsed.indexOf(hsvColor);
                if (idx > -1) {
                    this.colorsUsed.splice(idx, 1);
                }
            } else {
                ErrorMetric.log('Chat.userLeft => "' + userName + '" is not a valid key');
            }
        } else {
            ErrorMetric.log('Chat.userLeft => userLeft invoked without Chat.userName');
        }

        return this;
    };

    /**
     * Adds a notification to the chat.
     *
     * @param {String} message - The notification
     * message to be added to the Chat interface.
     * @returns {Object} The current Chat instance.
     * @public
     */
    this.addNotification = function(message) {
        var _this = this;
        var content = this.notificationTmpl({
            room: _this.roomName,
            msg: message
        });
        this._appendLine(content);

        return this;
    };

    /**
     * Handles peer messages; called by the peer message handler in `room.js.`
     * The content is a raw message object. Originally, `room.js` called
     * `.addMessage()`, but since we are adding new features, this provides
     * more versatility.
     *
     * @param {String} peerId - The peer ID whose message we're handling.
     * @param {String} content - The message content.
     * @returns {undefined} undefined
     * @public
     */
    this.handlePeerMessage = function(peerId, content) {
        if (typeof content.msg === 'string') {
            // New Chat Message message
            //   {
            //     msg : string
            //   }
            this.addMessage(peerId, content.msg);
        // @todo (potato): implement potato message handler here
        } else if (typeof content.cmd === 'string') {
            // Chat Command Message
            //   {
            //     cmd : string,
            //     cmdData : Object | null
            //   }
            ChatCommands.handlePeerMessage(content.cmd, peerId, content.cmdData);
        } else {
            ErrorMetric.log('Chat.handlePeerMessage => invalid chat peer message from ' + peerId);
        }
    };

    /**
     * Handles chat commands (if valid). If a valid chat command is encountered,
     * this function returns true.
     *
     * @param {String} message - Chat command.
     * @returns {Boolean} True if the command provided
     * was valid, false otherwise.
     * @private
     */
    var _handleChatCommand = function(message) {
        return ChatCommands.handleCommand(message);
    };

    /**
     * Adds a message to the chat.
     *
     * @param {String} peerId - The peer ID of the
     * user that sent a message.
     * @param {String} message - The contents of the
     * message sent by peerId.
     * @returns {Object} The current Chat instance.
     * @public
     */
    this.addMessage = function(peerId, message) {
        if (this.peerId !== null) {
            var userName = _peerIdMap[peerId];
            var hsvColor = this.peerColorMap[peerId];
            if (hsvColor !== undefined && userName !== undefined) {
                var content = null;

                if (!_handleChatCommand(message)) {
                    // @todo (input): userName and message come from user input
                    if (_lastPeerIdMessage !== peerId) {
                        content = this.messageTmpl({
                            color: hsvColor,
                            user: userName,
                            id: peerId,
                            msg: message,
                            time: getTimeStamp()
                        });
                    } else {
                        content = this.msgContinueTmpl({
                            msg: message
                        });
                    }
                    this._appendLine(content);
                    _lastPeerIdMessage = peerId;
                } else {
                    _lastCommand = message;
                }
            } else {
                ErrorMetric.log('Chat.addMessage => "' + peerId + '" is not a valid key');
            }
        } else {
            ErrorMetric.log('Chat.addMessage => addMessage invoked without Chat.userName');
        }

        return this;
    };

    /**
     * Sets up the user controls, binds the user name, and registers a callback for
     * the Chat UI. This connects the components such that messages can be sent out.
     *
     * @param {String} peerId - The peer ID of the current user.
     * @param {String} userName - The name of the current user.
     * @param {Function} sendMessageFn - A callback that gets passed the message
     * from the text entry field. Takes a message string and returns a boolean
     * (true on success, false on failure). This should tie-in with a backend that
     * does the busy work of actually sending the message.
     * @returns {Object} The current Chat instance.
     * @public
     */
    this.initialize = function(peerId, userName, sendMessageFn) {
        var _this = this;

        this.peerId = peerId;
        this.userName = userName;

        this.sendMessage = sendMessageFn;

        // @todo FIXME: it would be cool to have some text here...
        this.addNotification('Welcome! Feel free to use this to communicate.');
        this.addNotification('To see a list of chatroom commands, type /help');
        this.userEntered(peerId, userName);

        // Used for detection of new lines in the chat text entry
        var _lastTextEntryHeight = _chatTextEntry.height();

        ChatCommands.initialize(this);

        var defaultText = 'Type message here...';
        _chatTextEntry
            .prop('disabled', false)
            .blur(function() {
                var msg = $.trim(_chatTextEntry.text());
                if (msg.length === 0) {
                    _chatTextEntry
                        .css('font-style', 'italic')
                        .css('color', _this.kIdleTextColor)
                        .text(defaultText);
                }
            })
            .focus(function() {
                var msg = $.trim(_chatTextEntry.text());
                if (msg === defaultText || msg.length === 0) {
                    _chatTextEntry
                        .css('font-style', 'normal')
                        .css('color', _this.kActiveTextColor)
                        .text('');
                }
            })
            .keyup(function(e) {
                var resizePanes = false;

                // Handles the ENTER key so we can send a chat message
                if (e.which === 13) {
                    var msg = _chatTextEntry.text();
                    if (sendMessageFn({
                        msg: msg
                    })) {
                        _this.addMessage(_this.peerId, msg);
                    } else {
                        ErrorMetric.log('chatTextEntry.click() => failed to send message');
                        _this.addNotification('Failed to send last message');
                    }

                    _chatTextEntry
                        .text('');
                    resizePanes = true;
                } else if (e.which === 38) {
                    // @todo (last command): implement
                    var val = _chatTextEntry.text();
                    if (val.length === 0 && _lastCommand !== null) {
                        _chatTextEntry
                            .text(_lastCommand);
                    }
                } else {
                    // All other keystrokes, we check to see if it causes
                    if (_lastTextEntryHeight !== _chatTextEntry.height()) {
                        resizePanes = true;
                    }
                }

                if (resizePanes) {
                    _lastTextEntryHeight = _chatTextEntry.height();
                    resizeChatPanes();
                }
            })
            .css('font-style', 'italic')
            .css('color', _this.kIdleTextColor)
            .attr('contenteditable', 'true')
            .text(defaultText);

        Notification.requestPermission(function(permission) {
            if (permission === 'granted') {
                _this.showNotifications = true;
            } else {
                ErrorMetric.log('Chat.initialize -> Notifications are denied');
            }
        });

        return this;
    };

    /**
     * Slides the chat panel down.
     *
     * @returns {Object} The current Chat instance.
     * @public
     */
    this.show = function() {
        $('.chatPanel')
            .stop(false, true)
            .slideDown(function() {
                resizeChatPanes();
            });

        return this;
    };

    return this;
};

// Force a redraw of the control pane
resizeChatPanes();

$(window).resize(function() {
    resizeChatPanes();
});