/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */
const DBus = imports.dbus;
const GLib = imports.gi.GLib;
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 Gettext = imports.gettext.domain('gnome-shell');
const _ = Gettext.gettext;
const C_ = Gettext.pgettext;
const History = imports.misc.history;
const Main = imports.ui.main;
const MessageTray = imports.ui.messageTray;
// See Notification.appendMessage
const SCROLLBACK_IMMEDIATE_TIME = 60; // 1 minute
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;
const NotificationDirection = {
SENT: 'chat-sent',
RECEIVED: 'chat-received'
};
let contactFeatures = [Tp.ContactFeature.ALIAS,
Tp.ContactFeature.AVATAR_DATA,
Tp.ContactFeature.PRESENCE];
// This is GNOME Shell's implementation of the Telepathy 'Client'
// interface. Specifically, the shell is a Telepathy 'Observer', which
// lets us see messages even if they belong to another app (eg,
// Empathy).
function makeMessageFromTpMessage(tpMessage, direction) {
let [text, flags] = tpMessage.to_text();
return {
messageType: tpMessage.get_message_type(),
text: text,
sender: tpMessage.sender.alias,
timestamp: tpMessage.get_received_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
};
}
function Client() {
this._init();
};
Client.prototype = {
_init : function() {
// channel path -> Source
this._sources = {};
// 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.
let dbus = Tp.DBusDaemon.dup();
this._observer = Tp.SimpleObserver.new(dbus, true, 'GnomeShell', true,
Lang.bind(this, this._observeChannels));
// We only care about single-user text-based chats
let props = {};
props[Tp.PROP_CHANNEL_CHANNEL_TYPE] = Tp.IFACE_CHANNEL_TYPE_TEXT;
props[Tp.PROP_CHANNEL_TARGET_HANDLE_TYPE] = Tp.HandleType.CONTACT;
this._observer.add_observer_filter(props);
try {
this._observer.register();
} catch (e) {
throw new Error('Couldn\'t register SimpleObserver. Error: \n' + e);
}
},
_observeChannels: function(observer, account, conn, channels,
dispatchOp, requests, context) {
// If the self_contact doesn't have the ALIAS, make sure
// to fetch it before trying to grab the channels.
let self_contact = conn.get_self_contact();
if (self_contact.has_feature(Tp.ContactFeature.ALIAS)) {
this._finishObserveChannels(account, conn, channels, context);
} else {
Shell.get_self_contact_features(conn,
contactFeatures.length, contactFeatures,
Lang.bind(this, function() {
this._finishObserveChannels(account, conn, channels, context);
}));
context.delay();
}
},
_finishObserveChannels: function(account, conn, channels, context) {
let len = channels.length;
for (let i = 0; i < len; i++) {
let channel = channels[i];
let [targetHandle, targetHandleType] = channel.get_handle();
/* Only observe contact text channels */
if ((!(channel instanceof Tp.TextChannel)) ||
targetHandleType != Tp.HandleType.CONTACT)
continue;
/* Request a TpContact */
Shell.get_tp_contacts(conn, 1, [targetHandle],
contactFeatures.length, contactFeatures,
Lang.bind(this, function (connection, contacts, failed) {
if (contacts.length < 1)
return;
/* We got the TpContact */
this._createSource(account, conn, channel, contacts[0]);
}), null);
}
context.accept();
},
_createSource: function(account, conn, channel, contact) {
if (this._sources[channel.get_object_path()])
return;
let source = new Source(account, conn, channel, contact);
this._sources[channel.get_object_path()] = source;
source.connect('destroy', Lang.bind(this,
function() {
delete this._sources[channel.get_object_path()];
}));
}
};
function Source(account, conn, channel, contact) {
this._init(account, conn, channel, contact);
}
Source.prototype = {
__proto__: MessageTray.Source.prototype,
_init: function(account, conn, channel, contact) {
MessageTray.Source.prototype._init.call(this, contact.get_alias());
this.isChat = true;
this._account = account;
this._contact = contact;
this._conn = conn;
this._channel = channel;
this._closedId = this._channel.connect('invalidated', Lang.bind(this, this._channelClosed));
this._notification = new Notification(this);
this._notification.setUrgency(MessageTray.Urgency.HIGH);
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._setSummaryIcon(this.createNotificationIcon());
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.pushNotification(this._notification);
this._getLogMessages();
},
_updateAlias: function() {
let oldAlias = this.title;
this.title = this._contact.get_alias();
this._notification.appendAliasChange(oldAlias, this.title);
this.pushNotification(this._notification);
},
createNotificationIcon: function() {
this._iconBox = new St.Bin({ style_class: 'avatar-box' });
this._iconBox._size = this.ICON_SIZE;
this._updateAvatarIcon();
return this._iconBox;
},
_updateAvatarIcon: function() {
let textureCache = St.TextureCache.get_default();
let file = this._contact.get_avatar_file();
if (file) {
let uri = file.get_uri();
this._iconBox.child = textureCache.load_uri_async(uri, this._iconBox._size, this._iconBox._size);
} else {
this._iconBox.child = new St.Icon({ icon_name: 'avatar-default',
icon_type: St.IconType.FULLCOLOR,
icon_size: this._iconBox._size });
}
},
open: function(notification) {
let props = {};
props[Tp.PROP_CHANNEL_CHANNEL_TYPE] = Tp.IFACE_CHANNEL_TYPE_TEXT;
[props[Tp.PROP_CHANNEL_TARGET_HANDLE], props[Tp.PROP_CHANNEL_TARGET_HANDLE_TYPE]] = this._channel.get_handle();
let req = Tp.AccountChannelRequest.new(this._account, props, global.get_current_time());
req.ensure_channel_async('', null, null);
},
_getLogMessages: function() {
let logManager = Tpl.LogManager.dup_singleton();
let entity = Tpl.Entity.new_from_tp_contact(this._contact, Tpl.EntityType.CONTACT);
Shell.get_contact_events(logManager,
this._account, entity,
SCROLLBACK_HISTORY_LINES,
Lang.bind(this, this._displayPendingMessages));
},
_displayPendingMessages: function(logManager, result) {
let [success, events] = logManager.get_filtered_events_finish(result);
let logMessages = events.map(makeMessageFromTplEvent);
let pendingTpMessages = this._channel.get_pending_messages();
let pendingMessages = pendingTpMessages.map(function (tpMessage) { return makeMessageFromTpMessage(tpMessage, NotificationDirection.RECEIVED); });
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();
},
_channelClosed: function() {
this._channel.disconnect(this._closedId);
this._channel.disconnect(this._receivedId);
this._channel.disconnect(this._sentId);
this._contact.disconnect(this._notifyAliasId);
this._contact.disconnect(this._notifyAvatarId);
this._contact.disconnect(this._presenceChangedId);
this.destroy();
},
_messageReceived: function(channel, message) {
message = makeMessageFromTpMessage(message, NotificationDirection.RECEIVED);
this._notification.appendMessage(message);
this.notify();
},
// This is called for both messages we send from
// our client and other clients as well.
_messageSent: function(channel, message, flags, token) {
message = makeMessageFromTpMessage(message, NotificationDirection.SENT);
this._notification.appendMessage(message);
},
notify: function() {
MessageTray.Source.prototype.notify.call(this, 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, null);
},
_presenceChanged: function (contact, presence, type, status, message) {
let msg, shouldNotify, title;
if (this._presence == presence)
return;
title = GLib.markup_escape_text(this.title, -1);
if (presence == Tp.ConnectionPresenceType.AVAILABLE) {
msg = _("%s is online.").format(title);
shouldNotify = (this._presence == Tp.ConnectionPresenceType.OFFLINE);
} else if (presence == Tp.ConnectionPresenceType.OFFLINE ||
presence == Tp.ConnectionPresenceType.EXTENDED_AWAY) {
presence = Tp.ConnectionPresenceType.OFFLINE;
msg = _("%s is offline.").format(title);
shouldNotify = (this._presence != Tp.ConnectionPresenceType.OFFLINE);
} else if (presence == Tp.ConnectionPresenceType.AWAY) {
msg = _("%s is away.").format(title);
shouldNotify = false;
} else if (presence == Tp.ConnectionPresenceType.BUSY) {
msg = _("%s is busy.").format(title);
shouldNotify = false;
} else
return;
this._presence = presence;
if (message)
msg += ' (' + GLib.markup_escape_text(message, -1) + ')';
this._notification.appendPresence(msg, shouldNotify);
if (shouldNotify)
this.notify();
}
};
function Notification(source) {
this._init(source);
}
Notification.prototype = {
__proto__: MessageTray.Notification.prototype,
_init: function(source) {
MessageTray.Notification.prototype._init.call(this, source, source.title, null, { customContent: true });
this.setResident(true);
this._responseEntry = new St.Entry({ style_class: 'chat-response',
can_focus: true });
this._responseEntry.clutter_text.connect('activate', Lang.bind(this, this._onEntryActivated));
this.setActionArea(this._responseEntry);
this._oldMaxScrollAdjustment = 0;
this._createScrollArea();
this._scrollArea.vscroll.adjustment.connect('changed', Lang.bind(this, function(adjustment) {
let currentValue = adjustment.value + adjustment.page_size;
if (currentValue == this._oldMaxScrollAdjustment)
this.scrollTo(St.Side.BOTTOM);
this._oldMaxScrollAdjustment = adjustment.upper;
}));
this._inputHistory = new History.HistoryManager({ entry: this._responseEntry.clutter_text });
this._history = [];
this._timestampTimeoutId = 0;
},
/**
* 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
* @styles: A list of CSS class names.
*/
appendMessage: function(message, noTimestamp, styles) {
let messageBody = GLib.markup_escape_text(message.text, -1);
styles = styles || [];
styles.push(message.direction);
if (message.messageType == Tp.ChannelTextMessageType.ACTION) {
let senderAlias = GLib.markup_escape_text(message.sender, -1);
messageBody = '%s %s'.format(senderAlias, messageBody);
styles.push('chat-action');
}
this.update(this.source.title, messageBody, { customContent: true, bannerMarkup: true });
this._append(messageBody, styles, message.timestamp, noTimestamp);
},
_append: function(text, styles, timestamp, noTimestamp) {
let currentTime = (Date.now() / 1000);
if (!timestamp)
timestamp = currentTime;
let lastMessageTime = -1;
if (this._history.length > 0)
lastMessageTime = this._history[0].time;
// Reset the old message timeout
if (this._timestampTimeoutId)
Mainloop.source_remove(this._timestampTimeoutId);
let body = this.addBody(text, true);
for (let i = 0; i < styles.length; i ++)
body.add_style_class_name(styles[i]);
this._history.unshift({ actor: body, time: timestamp, realMessage: true });
if (!noTimestamp) {
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));
}
if (this._history.length > 1) {
// 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._history.filter(function(item) { return item.realMessage });
if (filteredHistory.length > maxLength) {
let lastMessageToKeep = filteredHistory[maxLength];
let expired = this._history.splice(this._history.indexOf(lastMessageToKeep));
for (let i = 0; i < expired.length; i++)
expired[i].actor.destroy();
}
}
},
_formatTimestamp: function(date) {
let now = new Date();
var daysAgo = (now.getTime() - date.getTime()) / (24 * 60 * 60 * 1000);
// Show a week day and time if date is in the last week
if (daysAgo < 1 || (daysAgo < 7 && now.getDay() != date.getDay())) {
/* Translators: this is a time format string followed by a date.
If applicable, replace %X with a strftime format valid for your
locale, without seconds. */
// xgettext:no-c-format
format = _("Sent at %X on %A");
// FIXME: The next two are stolen from calendar.js with the comment to avoid
// a string-freeze break. They should be replaced with better strings
// with 'Sent at', appropriate context and appropriate translator comment.
} else if (date.getYear() == now.getYear()) {
/* Translators: Shown on calendar heading when selected day occurs on current year */
format = C_("calendar heading", "%A, %B %d");
} else {
/* Translators: Shown on calendar heading when selected day occurs on different year */
format = C_("calendar heading", "%A, %B %d, %Y");
}
return date.toLocaleFormat(format);
},
appendTimestamp: function() {
let lastMessageTime = this._history[0].time;
let lastMessageDate = new Date(lastMessageTime * 1000);
let timeLabel = this.addBody(this._formatTimestamp(lastMessageDate), false, { expand: true, x_fill: false, x_align: St.Align.END });
timeLabel.add_style_class_name('chat-meta-message');
this._history.unshift({ actor: timeLabel, time: lastMessageTime, realMessage: false });
this._timestampTimeoutId = 0;
return false;
},
appendPresence: function(text, asTitle) {
if (asTitle)
this.update(text, null, { customContent: true, titleMarkup: true });
else
this.update(this.source.title, null, { customContent: true });
let label = this.addBody(text, true);
label.add_style_class_name('chat-meta-message');
this._history.unshift({ actor: label, time: (Date.now() / 1000), realMessage: false});
},
appendAliasChange: function(oldAlias, newAlias) {
// FIXME: uncomment this after 3.0 string freeze ends
// 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 = '' + _("%s is now known as %s").format(oldAlias, newAlias) + '';
// let label = this.addBody(message, true);
// label.add_style_class_name('chat-meta-message');
// this._history.unshift({ actor: label, time: (Date.now() / 1000), realMessage: false });
// this.update(newAlias, null, { customContent: true });
},
_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.source.respond(text);
}
};