// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-

const Clutter = imports.gi.Clutter;
const Gio = imports.gi.Gio;
const GLib = imports.gi.GLib;
const Gtk = imports.gi.Gtk;
const Lang = imports.lang;
const Mainloop = imports.mainloop;
const Shell = imports.gi.Shell;
const Signals = imports.signals;
const St = imports.gi.St;
const Tpl = imports.gi.TelepathyLogger;
const Tp = imports.gi.TelepathyGLib;

const Calendar = imports.ui.calendar;
const History = imports.misc.history;
const Main = imports.ui.main;
const MessageTray = imports.ui.messageTray;
const Params = imports.misc.params;
const PopupMenu = imports.ui.popupMenu;
const Util = imports.misc.util;

// See Notification.appendMessage
const SCROLLBACK_IMMEDIATE_TIME = 3 * 60; // 3 minutes
const SCROLLBACK_RECENT_TIME = 15 * 60; // 15 minutes
const SCROLLBACK_RECENT_LENGTH = 20;
const SCROLLBACK_IDLE_LENGTH = 5;

// See Source._displayPendingMessages
const SCROLLBACK_HISTORY_LINES = 10;

// See Notification._onEntryChanged
const COMPOSING_STOP_TIMEOUT = 5;

const CHAT_EXPAND_LINES = 12;

const NotificationDirection = {
    SENT: 'chat-sent',
    RECEIVED: 'chat-received'
};

const N_ = function(s) { return s; };

function makeMessageFromTpMessage(tpMessage, direction) {
    let [text, flags] = tpMessage.to_text();

    let timestamp = tpMessage.get_sent_timestamp();
    if (timestamp == 0)
        timestamp = tpMessage.get_received_timestamp();

    return {
        messageType: tpMessage.get_message_type(),
        text: text,
        sender: tpMessage.sender.alias,
        timestamp: timestamp,
        direction: direction
    };
}


function makeMessageFromTplEvent(event) {
    let sent = event.get_sender().get_entity_type() == Tpl.EntityType.SELF;
    let direction = sent ? NotificationDirection.SENT : NotificationDirection.RECEIVED;

    return {
        messageType: event.get_message_type(),
        text: event.get_message(),
        sender: event.get_sender().get_alias(),
        timestamp: event.get_timestamp(),
        direction: direction
    };
}

const TelepathyClient = new Lang.Class({
    Name: 'TelepathyClient',

    _init: function() {
        // channel path -> ChatSource
        this._chatSources = {};
        this._chatState = Tp.ChannelChatState.ACTIVE;

        // account path -> AccountNotification
        this._accountNotifications = {};

        // Define features we want
        this._accountManager = Tp.AccountManager.dup();
        let factory = this._accountManager.get_factory();
        factory.add_account_features([Tp.Account.get_feature_quark_connection()]);
        factory.add_connection_features([Tp.Connection.get_feature_quark_contact_list()]);
        factory.add_channel_features([Tp.Channel.get_feature_quark_contacts()]);
        factory.add_contact_features([Tp.ContactFeature.ALIAS,
                                      Tp.ContactFeature.AVATAR_DATA,
                                      Tp.ContactFeature.PRESENCE,
                                      Tp.ContactFeature.SUBSCRIPTION_STATES]);

        // Set up a SimpleObserver, which will call _observeChannels whenever a
        // channel matching its filters is detected.
        // The second argument, recover, means _observeChannels will be run
        // for any existing channel as well.
        this._tpClient = new Shell.TpClient({ name: 'GnomeShell',
                                              account_manager: this._accountManager,
                                              uniquify_name: true });
        this._tpClient.set_observe_channels_func(
            Lang.bind(this, this._observeChannels));
        this._tpClient.set_approve_channels_func(
            Lang.bind(this, this._approveChannels));
        this._tpClient.set_handle_channels_func(
            Lang.bind(this, this._handleChannels));

        // Allow other clients (such as Empathy) to pre-empt our channels if
        // needed
        this._tpClient.set_delegated_channels_callback(
            Lang.bind(this, this._delegatedChannelsCb));
    },

    enable: function() {
        try {
            this._tpClient.register();
        } catch (e) {
            throw new Error('Couldn\'t register Telepathy client. Error: \n' + e);
        }

        if (!this._accountManager.is_prepared(Tp.AccountManager.get_feature_quark_core()))
            this._accountManager.prepare_async(null, null);
    },

    disable: function() {
        this._tpClient.unregister();
    },

    _observeChannels: function(observer, account, conn, channels,
                               dispatchOp, requests, context) {
        let len = channels.length;
        for (let i = 0; i < len; i++) {
            let channel = channels[i];
            let [targetHandle, targetHandleType] = channel.get_handle();

            if (channel.get_invalidated())
              continue;

            /* Only observe contact text channels */
            if ((!(channel instanceof Tp.TextChannel)) ||
               targetHandleType != Tp.HandleType.CONTACT)
               continue;

            this._createChatSource(account, conn, channel, channel.get_target_contact());
        }

        context.accept();
    },

    _createChatSource: function(account, conn, channel, contact) {
        if (this._chatSources[channel.get_object_path()])
            return;

        let source = new ChatSource(account, conn, channel, contact, this._tpClient);

        this._chatSources[channel.get_object_path()] = source;
        source.connect('destroy', Lang.bind(this,
                       function() {
                           delete this._chatSources[channel.get_object_path()];
                       }));
    },

    _handleChannels: function(handler, account, conn, channels,
                              requests, user_action_time, context) {
        this._handlingChannels(account, conn, channels, true);
        context.accept();
    },

    _handlingChannels: function(account, conn, channels, notify) {
        let len = channels.length;
        for (let i = 0; i < len; i++) {
            let channel = channels[i];

            // We can only handle text channel, so close any other channel
            if (!(channel instanceof Tp.TextChannel)) {
                channel.close_async(null);
                continue;
            }

            if (channel.get_invalidated())
              continue;

            // 'notify' will be true when coming from an actual HandleChannels
            // call, and not when from a successful Claim call. The point is
            // we don't want to notify for a channel we just claimed which
            // has no new messages (for example, a new channel which only has
            // a delivery notification). We rely on _displayPendingMessages()
            // and _messageReceived() to notify for new messages.

            // But we should still notify from HandleChannels because the
            // Telepathy spec states that handlers must foreground channels
            // in HandleChannels calls which are already being handled.

            if (notify && this._tpClient.is_handling_channel(channel)) {
                // We are already handling the channel, display the source
                let source = this._chatSources[channel.get_object_path()];
                if (source)
                    source.notify();
            }
        }
    },

    _approveChannels: function(approver, account, conn, channels,
                               dispatchOp, context) {
        let channel = channels[0];
        let chanType = channel.get_channel_type();

        if (channel.get_invalidated()) {
            context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
                                        message: 'Channel is invalidated' }));
            return;
        }

        if (chanType == Tp.IFACE_CHANNEL_TYPE_TEXT)
            this._approveTextChannel(account, conn, channel, dispatchOp, context);
        else
            context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
                                        message: 'Unsupported channel type' }));
    },

    _approveTextChannel: function(account, conn, channel, dispatchOp, context) {
        let [targetHandle, targetHandleType] = channel.get_handle();

        if (targetHandleType != Tp.HandleType.CONTACT) {
            context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT,
                                        message: 'Unsupported handle type' }));
            return;
        }

        // Approve private text channels right away as we are going to handle it
        dispatchOp.claim_with_async(this._tpClient, Lang.bind(this, function(dispatchOp, result) {
            try {
                dispatchOp.claim_with_finish(result);
                this._handlingChannels(account, conn, [channel], false);
            } catch (err) {
                log('Failed to Claim channel: ' + err);
            }
        }));

        context.accept();
    },

    _delegatedChannelsCb: function(client, channels) {
        // Nothing to do as we don't make a distinction between observed and
        // handled channels.
    },
});

const ChatSource = new Lang.Class({
    Name: 'ChatSource',
    Extends: MessageTray.Source,

    _init: function(account, conn, channel, contact, client) {
        this._account = account;
        this._contact = contact;
        this._client = client;

        this.parent(contact.get_alias());

        this.isChat = true;
        this._pendingMessages = [];

        this._conn = conn;
        this._channel = channel;
        this._closedId = this._channel.connect('invalidated', Lang.bind(this, this._channelClosed));

        this._notifyTimeoutId = 0;

        this._presence = contact.get_presence_type();

        this._sentId = this._channel.connect('message-sent', Lang.bind(this, this._messageSent));
        this._receivedId = this._channel.connect('message-received', Lang.bind(this, this._messageReceived));
        this._pendingId = this._channel.connect('pending-message-removed', Lang.bind(this, this._pendingRemoved));

        this._notifyAliasId = this._contact.connect('notify::alias', Lang.bind(this, this._updateAlias));
        this._notifyAvatarId = this._contact.connect('notify::avatar-file', Lang.bind(this, this._updateAvatarIcon));
        this._presenceChangedId = this._contact.connect('presence-changed', Lang.bind(this, this._presenceChanged));

        // Add ourselves as a source.
        Main.messageTray.add(this);

        this._getLogMessages();
    },

    _ensureNotification: function() {
        if (this._notification)
            return;

        this._notification = new ChatNotification(this);
        this._notification.connect('activated', Lang.bind(this, this.open));
        this._notification.connect('updated', Lang.bind(this,
            function() {
                if (this._banner && this._banner.expanded)
                    this._ackMessages();
            }));
        this._notification.connect('destroy', Lang.bind(this,
            function() {
                this._notification = null;
            }));
        this.pushNotification(this._notification);
    },

    _createPolicy: function() {
        if (this._account.protocol_name == 'irc')
            return new MessageTray.NotificationApplicationPolicy('org.gnome.Polari');
        return new MessageTray.NotificationApplicationPolicy('empathy');
    },

    createBanner: function() {
        this._banner = new ChatNotificationBanner(this._notification);

        // We ack messages when the user expands the new notification
        let id = this._banner.connect('expanded', Lang.bind(this, this._ackMessages));
        this._banner.actor.connect('destroy', Lang.bind(this,
            function() {
                this._banner.disconnect(id);
                this._banner = null;
            }));

        return this._banner;
    },

    _updateAlias: function() {
        let oldAlias = this.title;
        let newAlias = this._contact.get_alias();

        if (oldAlias == newAlias)
            return;

        this.setTitle(newAlias);
        if (this._notification)
            this._notification.appendAliasChange(oldAlias, newAlias);
    },

    getIcon: function() {
        let file = this._contact.get_avatar_file();
        if (file) {
            return new Gio.FileIcon({ file: file });
        } else {
            return new Gio.ThemedIcon({ name: 'avatar-default' });
        }
    },

    getSecondaryIcon: function() {
        let iconName;
        let presenceType = this._contact.get_presence_type();

        switch (presenceType) {
            case Tp.ConnectionPresenceType.AVAILABLE:
                iconName = 'user-available';
                break;
            case Tp.ConnectionPresenceType.BUSY:
                iconName = 'user-busy';
                break;
            case Tp.ConnectionPresenceType.OFFLINE:
                iconName = 'user-offline';
                break;
            case Tp.ConnectionPresenceType.HIDDEN:
                iconName = 'user-invisible';
                break;
            case Tp.ConnectionPresenceType.AWAY:
                iconName = 'user-away';
                break;
            case Tp.ConnectionPresenceType.EXTENDED_AWAY:
                iconName = 'user-idle';
                break;
            default:
                iconName = 'user-offline';
       }
       return new Gio.ThemedIcon({ name: iconName });
    },

    _updateAvatarIcon: function() {
        this.iconUpdated();
        if (this._notifiction)
            this._notification.update(this._notification.title,
                                      this._notification.bannerBodyText,
                                      { gicon: this.getIcon() });
    },

    open: function() {
        Main.overview.hide();
        Main.panel.closeCalendar();

        if (this._client.is_handling_channel(this._channel)) {
            // We are handling the channel, try to pass it to Empathy or Polari
            // (depending on the channel type)
            // We don't check if either app is availble - mission control will
            // fallback to something else if activation fails

            let target;
            if (this._channel.connection.protocol_name == 'irc')
                target = 'org.freedesktop.Telepathy.Client.Polari';
            else
                target = 'org.freedesktop.Telepathy.Client.Empathy.Chat';
            this._client.delegate_channels_async([this._channel], global.get_current_time(), target, null);
        } else {
            // We are not the handler, just ask to present the channel
            let dbus = Tp.DBusDaemon.dup();
            let cd = Tp.ChannelDispatcher.new(dbus);

            cd.present_channel_async(this._channel, global.get_current_time(), null);
        }
    },

    _getLogMessages: function() {
        let logManager = Tpl.LogManager.dup_singleton();
        let entity = Tpl.Entity.new_from_tp_contact(this._contact, Tpl.EntityType.CONTACT);

        logManager.get_filtered_events_async(this._account, entity,
                                             Tpl.EventTypeMask.TEXT, SCROLLBACK_HISTORY_LINES,
                                             null, Lang.bind(this, this._displayPendingMessages));
    },

    _displayPendingMessages: function(logManager, result) {
        let [success, events] = logManager.get_filtered_events_finish(result);

        let logMessages = events.map(makeMessageFromTplEvent);
        this._ensureNotification();

        let pendingTpMessages = this._channel.get_pending_messages();
        let pendingMessages = [];

        for (let i = 0; i < pendingTpMessages.length; i++) {
            let message = pendingTpMessages[i];

            if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT)
                continue;

            pendingMessages.push(makeMessageFromTpMessage(message, NotificationDirection.RECEIVED));

            this._pendingMessages.push(message);
        }

        this.countUpdated();

        let showTimestamp = false;

        for (let i = 0; i < logMessages.length; i++) {
            let logMessage = logMessages[i];
            let isPending = false;

            // Skip any log messages that are also in pendingMessages
            for (let j = 0; j < pendingMessages.length; j++) {
                let pending = pendingMessages[j];
                if (logMessage.timestamp == pending.timestamp && logMessage.text == pending.text) {
                    isPending = true;
                    break;
                }
            }

            if (!isPending) {
                showTimestamp = true;
                this._notification.appendMessage(logMessage, true, ['chat-log-message']);
            }
        }

        if (showTimestamp)
            this._notification.appendTimestamp();

        for (let i = 0; i < pendingMessages.length; i++)
            this._notification.appendMessage(pendingMessages[i], true);

        if (pendingMessages.length > 0)
            this.notify();
    },

    destroy: function(reason) {
        if (this._client.is_handling_channel(this._channel)) {
            this._ackMessages();
            // The chat box has been destroyed so it can't
            // handle the channel any more.
            this._channel.close_async(function(channel, result) {
                channel.close_finish(result);
            });
        }

        // Keep source alive while the channel is open
        if (reason != MessageTray.NotificationDestroyedReason.SOURCE_CLOSED)
            return;

        if (this._destroyed)
            return;

        this._destroyed = true;
        this._channel.disconnect(this._closedId);
        this._channel.disconnect(this._receivedId);
        this._channel.disconnect(this._pendingId);
        this._channel.disconnect(this._sentId);

        this._contact.disconnect(this._notifyAliasId);
        this._contact.disconnect(this._notifyAvatarId);
        this._contact.disconnect(this._presenceChangedId);

        this.parent(reason);
    },

    _channelClosed: function() {
        this.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED);
    },

    /* All messages are new messages for Telepathy sources */
    get count() {
        return this._pendingMessages.length;
    },

    get unseenCount() {
        return this.count;
    },

    get countVisible() {
        return this.count > 0;
    },

    _messageReceived: function(channel, message) {
        if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT)
            return;

        this._ensureNotification();
        this._pendingMessages.push(message);
        this.countUpdated();

        message = makeMessageFromTpMessage(message, NotificationDirection.RECEIVED);
        this._notification.appendMessage(message);

        // Wait a bit before notifying for the received message, a handler
        // could ack it in the meantime.
        if (this._notifyTimeoutId != 0)
            Mainloop.source_remove(this._notifyTimeoutId);
        this._notifyTimeoutId = Mainloop.timeout_add(500,
            Lang.bind(this, this._notifyTimeout));
        GLib.Source.set_name_by_id(this._notifyTimeoutId, '[gnome-shell] this._notifyTimeout');
    },

    _notifyTimeout: function() {
        if (this._pendingMessages.length != 0)
            this.notify();

        this._notifyTimeoutId = 0;

        return GLib.SOURCE_REMOVE;
    },

    // This is called for both messages we send from
    // our client and other clients as well.
    _messageSent: function(channel, message, flags, token) {
        this._ensureNotification();
        message = makeMessageFromTpMessage(message, NotificationDirection.SENT);
        this._notification.appendMessage(message);
    },

    notify: function() {
        this.parent(this._notification);
    },

    respond: function(text) {
        let type;
        if (text.slice(0, 4) == '/me ') {
            type = Tp.ChannelTextMessageType.ACTION;
            text = text.slice(4);
        } else {
            type = Tp.ChannelTextMessageType.NORMAL;
        }

        let msg = Tp.ClientMessage.new_text(type, text);
        this._channel.send_message_async(msg, 0, Lang.bind(this, function (src, result) {
            this._channel.send_message_finish(result); 
        }));
    },

    setChatState: function(state) {
        // We don't want to send COMPOSING every time a letter is typed into
        // the entry. We send the state only when it changes. Telepathy/Empathy
        // might change it behind our back if the user is using both
        // gnome-shell's entry and the Empathy conversation window. We could
        // keep track of it with the ChatStateChanged signal but it is good
        // enough right now.
        if (state != this._chatState) {
          this._chatState = state;
          this._channel.set_chat_state_async(state, null);
        }
    },

    _presenceChanged: function (contact, presence, status, message) {
        if (this._notification)
            this._notification.update(this._notification.title,
                                      this._notification.bannerBodyText,
                                      { secondaryGIcon: this.getSecondaryIcon() });
    },

    _pendingRemoved: function(channel, message) {
        let idx = this._pendingMessages.indexOf(message);

        if (idx >= 0) {
            this._pendingMessages.splice(idx, 1);
            this.countUpdated();
        }

        if (this._pendingMessages.length == 0 &&
            this._banner && !this._banner.expanded)
            this._banner.hide();
    },

    _ackMessages: function() {
        // Don't clear our messages here, tp-glib will send a
        // 'pending-message-removed' for each one.
        this._channel.ack_all_pending_messages_async(null);
    }
});

const ChatNotification = new Lang.Class({
    Name: 'ChatNotification',
    Extends: MessageTray.Notification,

    _init: function(source) {
        this.parent(source, source.title, null,
                    { secondaryGIcon: source.getSecondaryIcon() });
        this.setUrgency(MessageTray.Urgency.HIGH);
        this.setResident(true);

        this.messages = [];
        this._timestampTimeoutId = 0;
    },

    destroy: function(reason) {
        if (this._timestampTimeoutId)
            Mainloop.source_remove(this._timestampTimeoutId);
        this._timestampTimeoutId = 0;
        this.parent(reason);
    },

    /**
     * appendMessage:
     * @message: An object with the properties:
     *   text: the body of the message,
     *   messageType: a #Tp.ChannelTextMessageType,
     *   sender: the name of the sender,
     *   timestamp: the time the message was sent
     *   direction: a #NotificationDirection
     * 
     * @noTimestamp: Whether to add a timestamp. If %true, no timestamp
     *   will be added, regardless of the difference since the
     *   last timestamp
     */
    appendMessage: function(message, noTimestamp) {
        let messageBody = GLib.markup_escape_text(message.text, -1);
        let styles = [message.direction];

        if (message.messageType == Tp.ChannelTextMessageType.ACTION) {
            let senderAlias = GLib.markup_escape_text(message.sender, -1);
            messageBody = '<i>%s</i> %s'.format(senderAlias, messageBody);
            styles.push('chat-action');
        }

        if (message.direction == NotificationDirection.RECEIVED)
            this.update(this.source.title, messageBody, { bannerMarkup: true });

        let group = (message.direction == NotificationDirection.RECEIVED ?
                     'received' : 'sent');

        this._append({ body: messageBody,
                       group: group,
                       styles: styles,
                       timestamp: message.timestamp,
                       noTimestamp: noTimestamp });
    },

    _filterMessages: function() {
        if (this.messages.length < 1)
            return;

        let lastMessageTime = this.messages[0].timestamp;
        let currentTime = (Date.now() / 1000);

        // Keep the scrollback from growing too long. If the most
        // recent message (before the one we just added) is within
        // SCROLLBACK_RECENT_TIME, we will keep
        // SCROLLBACK_RECENT_LENGTH previous messages. Otherwise
        // we'll keep SCROLLBACK_IDLE_LENGTH messages.

        let maxLength = (lastMessageTime < currentTime - SCROLLBACK_RECENT_TIME) ?
            SCROLLBACK_IDLE_LENGTH : SCROLLBACK_RECENT_LENGTH;

        let filteredHistory = this.messages.filter(function(item) { return item.realMessage });
        if (filteredHistory.length > maxLength) {
            let lastMessageToKeep = filteredHistory[maxLength];
            let expired = this.messages.splice(this.messages.indexOf(lastMessageToKeep));
            for (let i = 0; i < expired.length; i++)
                this.emit('message-removed', expired[i]);
        }
    },

    /**
     * _append:
     * @props: An object with the properties:
     *  body: The text of the message.
     *  group: The group of the message, one of:
     *         'received', 'sent', 'meta'.
     *  styles: Style class names for the message to have.
     *  timestamp: The timestamp of the message.
     *  noTimestamp: suppress timestamp signal?
     */
    _append: function(props) {
        let currentTime = (Date.now() / 1000);
        props = Params.parse(props, { body: null,
                                      group: null,
                                      styles: [],
                                      timestamp: currentTime,
                                      noTimestamp: false });

        // Reset the old message timeout
        if (this._timestampTimeoutId)
            Mainloop.source_remove(this._timestampTimeoutId);
        this._timestampTimeoutId = 0;

        let message = { realMessage: props.group != 'meta',
                        showTimestamp: false };
        Lang.copyProperties(props, message);
        delete message.noTimestamp;

        this.messages.unshift(message);
        this.emit('message-added', message);

        if (!props.noTimestamp) {
            let timestamp = props.timestamp;
            if (timestamp < currentTime - SCROLLBACK_IMMEDIATE_TIME) {
                this.appendTimestamp();
            } else {
                // Schedule a new timestamp in SCROLLBACK_IMMEDIATE_TIME
                // from the timestamp of the message.
                this._timestampTimeoutId = Mainloop.timeout_add_seconds(
                    SCROLLBACK_IMMEDIATE_TIME - (currentTime - timestamp),
                    Lang.bind(this, this.appendTimestamp));
                GLib.Source.set_name_by_id(this._timestampTimeoutId, '[gnome-shell] this.appendTimestamp');
            }
        }

        this._filterMessages();
    },

    appendTimestamp: function() {
        this._timestampTimeoutId = 0;

        this.messages[0].showTimestamp = true;
        this.emit('timestamp-changed', this.messages[0]);

        this._filterMessages();

        return GLib.SOURCE_REMOVE;
    },

    appendAliasChange: function(oldAlias, newAlias) {
        oldAlias = GLib.markup_escape_text(oldAlias, -1);
        newAlias = GLib.markup_escape_text(newAlias, -1);

        /* Translators: this is the other person changing their old IM name to their new
           IM name. */
        let message = '<i>' + _("%s is now known as %s").format(oldAlias, newAlias) + '</i>';

        this._append({ body: message,
                       group: 'meta',
                       styles: ['chat-meta-message'] });

        this._filterMessages();
    }
});

const ChatLineBox = new Lang.Class({
    Name: 'ChatLineBox',
    Extends: St.BoxLayout,

    vfunc_get_preferred_height: function(forWidth) {
        let [, natHeight] = this.parent(forWidth);
        return [natHeight, natHeight];
    }
});

const ChatNotificationBanner = new Lang.Class({
    Name: 'ChatNotificationBanner',
    Extends: MessageTray.NotificationBanner,

    _init: function(notification) {
        this.parent(notification);

        this._responseEntry = new St.Entry({ style_class: 'chat-response',
                                             x_expand: true,
                                             can_focus: true });
        this._responseEntry.clutter_text.connect('activate', Lang.bind(this, this._onEntryActivated));
        this._responseEntry.clutter_text.connect('text-changed', Lang.bind(this, this._onEntryChanged));
        this.setActionArea(this._responseEntry);

        this._responseEntry.clutter_text.connect('key-focus-in', Lang.bind(this, function() {
            this.focused = true;
        }));
        this._responseEntry.clutter_text.connect('key-focus-out', Lang.bind(this, function() {
            this.focused = false;
            this.emit('unfocused');
        }));

        this._scrollArea = new St.ScrollView({ style_class: 'chat-scrollview vfade',
                                               vscrollbar_policy: Gtk.PolicyType.AUTOMATIC,
                                               hscrollbar_policy: Gtk.PolicyType.NEVER,
                                               visible: this.expanded });
        this._contentArea = new St.BoxLayout({ style_class: 'chat-body',
                                               vertical: true });
        this._scrollArea.add_actor(this._contentArea);

        this.setExpandedBody(this._scrollArea);
        this.setExpandedLines(CHAT_EXPAND_LINES);

        this._lastGroup = null;

        // Keep track of the bottom position for the current adjustment and
        // force a scroll to the bottom if things change while we were at the
        // bottom
        this._oldMaxScrollValue = this._scrollArea.vscroll.adjustment.value;
        this._scrollArea.vscroll.adjustment.connect('changed', Lang.bind(this, function(adjustment) {
            if (adjustment.value == this._oldMaxScrollValue)
                this.scrollTo(St.Side.BOTTOM);
            this._oldMaxScrollValue = Math.max(adjustment.lower, adjustment.upper - adjustment.page_size);
        }));

        this._inputHistory = new History.HistoryManager({ entry: this._responseEntry.clutter_text });

        this._composingTimeoutId = 0;

        this._messageActors = new Map();

        this._messageAddedId = this.notification.connect('message-added',
            Lang.bind(this, function(n, message) {
                this._addMessage(message);
            }));
        this._messageRemovedId = this.notification.connect('message-removed',
            Lang.bind(this, function(n, message) {
                let actor = this._messageActors.get(message);
                if (this._messageActors.delete(message))
                    actor.destroy();
            }));
        this._timestampChangedId = this.notification.connect('timestamp-changed',
            Lang.bind(this, function(n, message) {
                this._updateTimestamp(message);
            }));

        for (let i = this.notification.messages.length - 1; i >= 0; i--)
            this._addMessage(this.notification.messages[i]);
    },

    _onDestroy: function() {
        this.parent();
        this.notification.disconnect(this._messageAddedId);
        this.notification.disconnect(this._messageRemovedId);
        this.notification.disconnect(this._timestampChangedId);
    },

    scrollTo: function(side) {
        let adjustment = this._scrollArea.vscroll.adjustment;
        if (side == St.Side.TOP)
            adjustment.value = adjustment.lower;
        else if (side == St.Side.BOTTOM)
            adjustment.value = adjustment.upper;
    },

    hide: function() {
        this.emit('done-displaying');
    },

    _addMessage: function(message) {
        let highlighter = new Calendar.URLHighlighter(message.body, true, true);
        let body = highlighter.actor;

        let styles = message.styles;
        for (let i = 0; i < styles.length; i++)
            body.add_style_class_name(styles[i]);

        let group = message.group;
        if (group != this._lastGroup) {
            this._lastGroup = group;
            body.add_style_class_name('chat-new-group');
        }

        let lineBox = new ChatLineBox();
        lineBox.add(body);
        this._contentArea.add_actor(lineBox);
        this._messageActors.set(message, lineBox);

        this._updateTimestamp(message);
    },

    _updateTimestamp: function(message) {
        let actor = this._messageActors.get(message);
        if (!actor)
            return;

        while (actor.get_n_children() > 1)
            actor.get_child_at_index(1).destroy();

        if (message.showTimestamp) {
            let lastMessageTime = message.timestamp;
            let lastMessageDate = new Date(lastMessageTime * 1000);

            let timeLabel = Util.createTimeLabel(lastMessageDate);
            timeLabel.style_class = 'chat-meta-message';
            timeLabel.x_expand = timeLabel.y_expand = true;
            timeLabel.x_align = timeLabel.y_align = Clutter.ActorAlign.END;

            actor.add_actor(timeLabel);
        }
    },

    _onEntryActivated: function() {
        let text = this._responseEntry.get_text();
        if (text == '')
            return;

        this._inputHistory.addItem(text);

        // Telepathy sends out the Sent signal for us.
        // see Source._messageSent
        this._responseEntry.set_text('');
        this.notification.source.respond(text);
    },

    _composingStopTimeout: function() {
        this._composingTimeoutId = 0;

        this.notification.source.setChatState(Tp.ChannelChatState.PAUSED);

        return GLib.SOURCE_REMOVE;
    },

    _onEntryChanged: function() {
        let text = this._responseEntry.get_text();

        // If we're typing, we want to send COMPOSING.
        // If we empty the entry, we want to send ACTIVE.
        // If we've stopped typing for COMPOSING_STOP_TIMEOUT
        //    seconds, we want to send PAUSED.

        // Remove composing timeout.
        if (this._composingTimeoutId > 0) {
            Mainloop.source_remove(this._composingTimeoutId);
            this._composingTimeoutId = 0;
        }

        if (text != '') {
            this.notification.source.setChatState(Tp.ChannelChatState.COMPOSING);

            this._composingTimeoutId = Mainloop.timeout_add_seconds(
                COMPOSING_STOP_TIMEOUT,
                Lang.bind(this, this._composingStopTimeout));
            GLib.Source.set_name_by_id(this._composingTimeoutId, '[gnome-shell] this._composingStopTimeout');
        } else {
            this.notification.source.setChatState(Tp.ChannelChatState.ACTIVE);
        }
    }
});

const Component = TelepathyClient;
