gnome-shell/js/ui/telepathyClient.js
Dan Winship e94d54bffb Add support for chatting directly from IM notifications
Also reorganizes the notification layout to use an StScrollView; very
tall notifications are now scrolled instead of just taking up more and
more of the screen.

https://bugzilla.gnome.org/show_bug.cgi?id=608999
2010-04-06 09:20:50 -04:00

476 lines
17 KiB
JavaScript

/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */
const Clutter = imports.gi.Clutter;
const DBus = imports.dbus;
const Lang = imports.lang;
const Shell = imports.gi.Shell;
const St = imports.gi.St;
const Main = imports.ui.main;
const MessageTray = imports.ui.messageTray;
const Telepathy = imports.misc.telepathy;
let avatarManager;
// See Notification.appendMessage
const SCROLLBACK_RECENT_TIME = 15 * 60; // 15 minutes
const SCROLLBACK_RECENT_LENGTH = 20;
const SCROLLBACK_IDLE_LENGTH = 5;
// This is GNOME Shell's implementation of the Telepathy "Client"
// interface. Specifically, the shell is a Telepathy "Approver", which
// lets us control the routing of incoming messages, a "Handler",
// which lets us receive and respond to messages, and an "Observer",
// which lets us see messages even if they belong to another app (eg,
// a conversation started from within Empathy).
function Client() {
this._init();
};
Client.prototype = {
_init : function() {
let name = Telepathy.CLIENT_NAME + '.GnomeShell';
DBus.session.exportObject(Telepathy.nameToPath(name), this);
DBus.session.acquire_name(name, DBus.SINGLE_INSTANCE,
function (name) { /* FIXME: acquired */ },
function (name) { /* FIXME: lost */ });
this._channels = {};
avatarManager = new AvatarManager();
// Acquire existing connections. (Needed to make things work
// through a restart.)
let accountManager = new Telepathy.AccountManager(DBus.session,
Telepathy.ACCOUNT_MANAGER_NAME,
Telepathy.nameToPath(Telepathy.ACCOUNT_MANAGER_NAME));
accountManager.GetRemote('ValidAccounts', Lang.bind(this, this._gotValidAccounts));
},
_gotValidAccounts: function(accounts, err) {
if (!accounts)
return;
for (let i = 0; i < accounts.length; i++) {
let account = new Telepathy.Account(DBus.session,
Telepathy.ACCOUNT_MANAGER_NAME,
accounts[i]);
account.GetRemote('Connection', Lang.bind(this,
function (connPath, err) {
if (!connPath || connPath == '/')
return;
let connReq = new Telepathy.ConnectionRequests(DBus.session,
Telepathy.pathToName(connPath),
connPath);
connReq.GetRemote('Channels', Lang.bind(this,
function(channels, err) {
if (!channels)
return;
this._addChannels(connPath, channels);
}));
}));
}
},
get Interfaces() {
return [ Telepathy.CLIENT_APPROVER_NAME,
Telepathy.CLIENT_HANDLER_NAME,
Telepathy.CLIENT_OBSERVER_NAME ];
},
get ApproverChannelFilter() {
return [
{ 'org.freedesktop.Telepathy.Channel.ChannelType': Telepathy.CHANNEL_TEXT_NAME }
];
},
AddDispatchOperation: function(channels, dispatchOperationPath, properties) {
let sender = DBus.getCurrentMessageContext().sender;
let op = new Telepathy.ChannelDispatchOperation(DBus.session, sender,
dispatchOperationPath);
op.ClaimRemote();
},
get HandlerChannelFilter() {
return [
{ 'org.freedesktop.Telepathy.Channel.ChannelType': Telepathy.CHANNEL_TEXT_NAME }
];
},
HandleChannels: function(account, connPath, channels,
requestsSatisfied, userActionTime,
handlerInfo) {
this._addChannels(connPath, channels);
},
get ObserverChannelFilter() {
return [
{ 'org.freedesktop.Telepathy.Channel.ChannelType': Telepathy.CHANNEL_TEXT_NAME }
];
},
ObserveChannels: function(account, connPath, channels,
dispatchOperation, requestsSatisfied,
observerInfo) {
this._addChannels(connPath, channels);
},
_addChannels: function(connPath, channelDetailsList) {
for (let i = 0; i < channelDetailsList.length; i++) {
let [channelPath, props] = channelDetailsList[i];
if (this._channels[channelPath])
continue;
let channelType = props[Telepathy.CHANNEL_NAME + '.ChannelType'];
if (channelType != Telepathy.CHANNEL_TEXT_NAME)
continue;
let targetHandle = props[Telepathy.CHANNEL_NAME + '.TargetHandle'];
let targetHandleType = props[Telepathy.CHANNEL_NAME + '.TargetHandleType'];
let targetId = props[Telepathy.CHANNEL_NAME + '.TargetID'];
let source = new Source(connPath, channelPath,
targetHandle, targetHandleType, targetId);
this._channels[channelPath] = source;
source.connect('destroy', Lang.bind(this,
function() {
delete this._channels[channelPath];
}));
}
}
};
DBus.conformExport(Client.prototype, Telepathy.ClientIface);
DBus.conformExport(Client.prototype, Telepathy.ClientApproverIface);
DBus.conformExport(Client.prototype, Telepathy.ClientHandlerIface);
DBus.conformExport(Client.prototype, Telepathy.ClientObserverIface);
function AvatarManager() {
this._init();
};
AvatarManager.prototype = {
_init: function() {
this._connections = {};
},
_addConnection: function(conn) {
if (this._connections[conn.getPath()])
return this._connections[conn.getPath()];
let info = {};
// avatarData[handle] describes the icon for @handle:
// either the string 'default', meaning to use the default
// avatar, or an array of bytes containing, eg, PNG data.
info.avatarData = {};
// icons[handle] is an array of the icon actors currently
// being displayed for @handle. These will be updated
// automatically if @handle's avatar changes.
info.icons = {};
info.connectionAvatars = new Telepathy.ConnectionAvatars(DBus.session,
conn.getBusName(),
conn.getPath());
info.updatedId = info.connectionAvatars.connect(
'AvatarUpdated', Lang.bind(this, this._avatarUpdated));
info.retrievedId = info.connectionAvatars.connect(
'AvatarRetrieved', Lang.bind(this, this._avatarRetrieved));
info.statusChangedId = conn.connect('StatusChanged', Lang.bind(this,
function (status, reason) {
if (status == Telepathy.ConnectionStatus.DISCONNECTED)
this._removeConnection(conn);
}));
this._connections[conn.getPath()] = info;
return info;
},
_removeConnection: function(conn) {
let info = this._connections[conn.getPath()];
if (!info)
return;
conn.disconnect(info.statusChangedId);
info.connectionAvatars.disconnect(info.updatedId);
info.connectionAvatars.disconnect(info.retrievedId);
delete this._connections[conn.getPath()];
},
_avatarUpdated: function(conn, handle, token) {
let info = this._connections[conn.getPath()];
if (!info)
return;
if (!info.avatarData[handle]) {
// This would only happen if either (a) the initial
// RequestAvatars() call hasn't returned yet, or (b)
// Telepathy is informing us about avatars we didn't ask
// about. Either way, we don't have to do anything here.
return;
}
if (token == '') {
// Invoke the next async callback in the chain, telling
// it to use the default image.
this._avatarRetrieved(conn, handle, token, 'default', null);
} else {
// In this case, @token is some sort of UUID. Telepathy
// expects us to cache avatar images to disk and use the
// tokens to figure out when we already have the right
// images cached. But we don't do that, we just
// ignore @token and request the image unconditionally.
info.connectionAvatars.RequestAvatarsRemote([handle]);
}
},
_createIcon: function(iconData, size) {
let textureCache = St.TextureCache.get_default();
if (iconData == 'default')
return textureCache.load_icon_name('stock_person', size);
else
return textureCache.load_from_data(iconData, iconData.length, size);
},
_avatarRetrieved: function(conn, handle, token, avatarData, mimeType) {
let info = this._connections[conn.getPath()];
if (!info)
return;
info.avatarData[handle] = avatarData;
if (!info.icons[handle])
return;
for (let i = 0; i < info.icons[handle].length; i++) {
let iconBox = info.icons[handle][i];
let size = iconBox.child.height;
iconBox.child = this._createIcon(avatarData, size);
}
},
createAvatar: function(conn, handle, size) {
let iconBox = new St.Bin({ style_class: 'avatar-box' });
let info = this._connections[conn.getPath()];
if (!info)
info = this._addConnection(conn);
if (!info.icons[handle])
info.icons[handle] = [];
info.icons[handle].push(iconBox);
iconBox.connect('destroy', Lang.bind(this,
function() {
let i = info.icons[handle].indexOf(iconBox);
if (i != -1)
info.icons[handle].splice(i, 1);
}));
let avatarData = info.avatarData[handle];
if (avatarData) {
iconBox.child = this._createIcon(avatarData, size);
return iconBox;
}
// Fill in the default icon and then asynchronously load
// the real avatar.
iconBox.child = this._createIcon('default', size);
info.connectionAvatars.GetKnownAvatarTokensRemote([handle], Lang.bind(this,
function (tokens, err) {
if (tokens && tokens[handle])
info.connectionAvatars.RequestAvatarsRemote([handle]);
else
info.avatarData[handle] = 'default';
}));
return iconBox;
}
};
function Source(connPath, channelPath, targetHandle, targetHandleType, targetId) {
this._init(connPath, channelPath, targetHandle, targetHandleType, targetId);
}
Source.prototype = {
__proto__: MessageTray.Source.prototype,
_init: function(connPath, channelPath, targetHandle, targetHandleType, targetId) {
MessageTray.Source.prototype._init.call(this, targetId);
let connName = Telepathy.pathToName(connPath);
this._conn = new Telepathy.Connection(DBus.session, connName, connPath);
this._channel = new Telepathy.Channel(DBus.session, connName, channelPath);
this._closedId = this._channel.connect('Closed', Lang.bind(this, this._channelClosed));
this._targetHandle = targetHandle;
this._targetId = targetId;
this.name = this._targetId;
if (targetHandleType == Telepathy.HandleType.CONTACT) {
let aliasing = new Telepathy.ConnectionAliasing(DBus.session, connName, connPath);
aliasing.RequestAliasesRemote([this._targetHandle], Lang.bind(this,
function (aliases, err) {
if (aliases && aliases.length)
this.name = aliases[0];
}));
}
this._channelText = new Telepathy.ChannelText(DBus.session, connName, channelPath);
this._receivedId = this._channelText.connect('Received', Lang.bind(this, this._messageReceived));
this._channelText.ListPendingMessagesRemote(false, Lang.bind(this, this._gotPendingMessages));
},
createIcon: function(size) {
return avatarManager.createAvatar(this._conn, this._targetHandle, size);
},
_gotPendingMessages: function(msgs, err) {
if (!msgs)
return;
for (let i = 0; i < msgs.length; i++)
this._messageReceived.apply(this, [this._channel].concat(msgs[i]));
},
_channelClosed: function() {
this._channel.disconnect(this._closedId);
this._channelText.disconnect(this._receivedId);
this.destroy();
},
_messageReceived: function(channel, id, timestamp, sender,
type, flags, text) {
if (!Main.messageTray.contains(this))
Main.messageTray.add(this);
if (!this._notification)
this._notification = new Notification(this._targetId, this);
this._notification.appendMessage(text);
this.notify(this._notification);
this._channelText.AcknowledgePendingMessagesRemote([id]);
},
respond: function(text) {
this._channelText.SendRemote(Telepathy.ChannelTextMessageType.NORMAL, text);
}
};
function Notification(id, source) {
this._init(id, source);
}
Notification.prototype = {
__proto__: MessageTray.Notification.prototype,
_init: function(id, source) {
MessageTray.Notification.prototype._init.call(this, id, source, source.name);
this.actor.connect('button-press-event', Lang.bind(this, this._onButtonPress));
this._responseEntry = new St.Entry({ style_class: 'chat-response' });
this._responseEntry.clutter_text.connect('key-focus-in', Lang.bind(this, this._onEntryFocused));
this._responseEntry.clutter_text.connect('activate', Lang.bind(this, this._onEntryActivated));
this.setActionArea(this._responseEntry);
this._history = [];
},
appendMessage: function(text) {
this.update(this.source.name, text);
this._append(text, 'chat-received');
},
_append: function(text, style) {
let body = this.addBody(text);
body.add_style_class_name(style);
this.scrollTo(St.Side.BOTTOM);
let now = new Date().getTime() / 1000;
this._history.unshift({ actor: body, time: now });
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 lastMessageTime = this._history[1].time;
let maxLength = (lastMessageTime < now - SCROLLBACK_RECENT_TIME) ?
SCROLLBACK_IDLE_LENGTH : SCROLLBACK_RECENT_LENGTH;
if (this._history.length > maxLength) {
let expired = this._history.splice(maxLength);
for (let i = 0; i < expired.length; i++)
expired[i].actor.destroy();
}
}
},
_onButtonPress: function(notification, event) {
if (!this._active)
return false;
let source = event.get_source ();
while (source) {
if (source == notification)
return false;
source = source.get_parent();
}
// @source is outside @notification, which has to mean that
// we have a pointer grab, and the user clicked outside the
// notification, so we should deactivate.
this._deactivate();
return true;
},
_onEntryFocused: function() {
if (this._active)
return;
if (!Main.pushModal(this.actor))
return;
Clutter.grab_pointer(this.actor);
this._active = true;
Main.messageTray.lock();
},
_onEntryActivated: function() {
let text = this._responseEntry.get_text();
if (text == '') {
this._deactivate();
return;
}
this._responseEntry.set_text('');
this._append(text, 'chat-sent');
this.source.respond(text);
},
_deactivate: function() {
if (this._active) {
Clutter.ungrab_pointer(this.actor);
Main.popModal(this.actor);
global.stage.set_key_focus(null);
// We have to do this after calling popModal(), because
// that will return the keyboard focus to
// this._responseEntry (because that's where it was when
// pushModal() was called), which will cause
// _onEntryFocused() to be called again, but we don't want
// it to do anything.
this._active = false;
Main.messageTray.unlock();
}
}
};