/* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */ const Clutter = imports.gi.Clutter; const DBus = imports.dbus; const GLib = imports.gi.GLib; const Lang = imports.lang; const Shell = imports.gi.Shell; const Signals = imports.signals; const St = imports.gi.St; const Gettext = imports.gettext.domain('gnome-shell'); const _ = Gettext.gettext; const Main = imports.ui.main; const MessageTray = imports.ui.messageTray; const Telepathy = imports.misc.telepathy; let contactManager; let channelDispatcher; // See Notification.appendMessage const SCROLLBACK_RECENT_TIME = 15 * 60; // 15 minutes const SCROLLBACK_RECENT_LENGTH = 20; const SCROLLBACK_IDLE_LENGTH = 5; // A 'Qualified_Property_Value_Map' that represents a single-user // text-based chat. let singleUserTextChannel = {}; singleUserTextChannel[Telepathy.CHANNEL_NAME + '.ChannelType'] = Telepathy.CHANNEL_TEXT_NAME; singleUserTextChannel[Telepathy.CHANNEL_NAME + '.TargetHandleType'] = Telepathy.HandleType.CONTACT; // Some protocols only support 'multi-user' chats, and single-user // chats are just treated as multi-user chats with only one other // participant. Telepathy uses HandleType.NONE for all chats in these // protocols; there's no good way for us to tell if the channel is // single- or multi-user. let oneOrMoreUserTextChannel = {}; oneOrMoreUserTextChannel[Telepathy.CHANNEL_NAME + '.ChannelType'] = Telepathy.CHANNEL_TEXT_NAME; oneOrMoreUserTextChannel[Telepathy.CHANNEL_NAME + '.TargetHandleType'] = Telepathy.HandleType.NONE; // The (non-chat) channel indicating the users whose presence // information we subscribe to let subscribedContactsChannel = {}; subscribedContactsChannel[Telepathy.CHANNEL_NAME + '.ChannelType'] = Telepathy.CHANNEL_CONTACT_LIST_NAME; subscribedContactsChannel[Telepathy.CHANNEL_NAME + '.TargetHandleType'] = Telepathy.HandleType.LIST; subscribedContactsChannel[Telepathy.CHANNEL_NAME + '.TargetID'] = 'subscribe'; // 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 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._accounts = {}; this._sources = {}; contactManager = new ContactManager(); contactManager.connect('presence-changed', Lang.bind(this, this._presenceChanged)); channelDispatcher = new Telepathy.ChannelDispatcher(DBus.session, Telepathy.CHANNEL_DISPATCHER_NAME, Telepathy.nameToPath(Telepathy.CHANNEL_DISPATCHER_NAME)); // 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, function (accounts, err) { if (!accounts) return; for (let i = 0; i < accounts.length; i++) this._gotAccount(accounts[i]); })); accountManager.connect('AccountValidityChanged', Lang.bind(this, this._accountValidityChanged)); }, _accountValidityChanged: function(accountManager, accountPath, valid) { if (!valid) { delete this._accounts[accountPath]; // We don't need to clean up connections, sources, etc; they'll // get Closed and cleaned up independently. } else this._gotAccount(accountPath); }, _gotAccount: function(accountPath) { let account = new Telepathy.Account(DBus.session, Telepathy.ACCOUNT_MANAGER_NAME, accountPath); this._accounts[accountPath] = account; 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(accountPath, connPath, channels); })); contactManager.addConnection(connPath); })); }, get Interfaces() { return [ Telepathy.CLIENT_OBSERVER_NAME ]; }, get ObserverChannelFilter() { return [ singleUserTextChannel, oneOrMoreUserTextChannel ]; }, ObserveChannels: function(accountPath, connPath, channels, dispatchOperation, requestsSatisfied, observerInfo) { this._addChannels(accountPath, connPath, channels); }, _addChannels: function(accountPath, connPath, channelDetailsList) { for (let i = 0; i < channelDetailsList.length; i++) { let [channelPath, props] = channelDetailsList[i]; // If this is being called from the startup code then it // won't have passed through our filters, so we need to // check the channel/targetHandle type ourselves. let channelType = props[Telepathy.CHANNEL_NAME + '.ChannelType']; if (channelType != Telepathy.CHANNEL_TEXT_NAME) continue; let targetHandleType = props[Telepathy.CHANNEL_NAME + '.TargetHandleType']; if (targetHandleType != Telepathy.HandleType.CONTACT && targetHandleType != Telepathy.HandleType.NONE) continue; let targetHandle = props[Telepathy.CHANNEL_NAME + '.TargetHandle']; let targetId = props[Telepathy.CHANNEL_NAME + '.TargetID']; if (this._sources[connPath + ':' + targetHandle]) continue; let source = new Source(accountPath, connPath, channelPath, targetHandle, targetHandleType, targetId); this._sources[connPath + ':' + targetHandle] = source; source.connect('destroy', Lang.bind(this, function() { delete this._sources[connPath + ':' + targetHandle]; })); } }, _presenceChanged: function(contactManager, connPath, handle, type, message) { let source = this._sources[connPath + ':' + handle]; if (!source) return; source.setPresence(type, message); } }; DBus.conformExport(Client.prototype, Telepathy.ClientIface); DBus.conformExport(Client.prototype, Telepathy.ClientObserverIface); function ContactManager() { this._init(); }; ContactManager.prototype = { _init: function() { this._connections = {}; // Note that if we changed this to '/telepathy/avatars' then // we would share cache files with empathy. But since this is // not documented/guaranteed, it seems a little sketchy this._cacheDir = GLib.get_user_cache_dir() + '/gnome-shell/avatars'; }, addConnection: function(connPath) { let info = this._connections[connPath]; if (info) return info; info = {}; // Figure out the cache subdirectory for this connection by // parsing the connection manager name (eg, 'gabble') and // protocol name (eg, 'jabber') from the Connection's path. // Telepathy requires the D-Bus path for a connection to have // a specific form, and explicitly says that clients are // allowed to parse it. let match = connPath.match(/\/org\/freedesktop\/Telepathy\/Connection\/([^\/]*\/[^\/]*)\/.*/); if (!match) throw new Error('Could not parse connection path ' + connPath); info.cacheDir = this._cacheDir + '/' + match[1]; GLib.mkdir_with_parents(info.cacheDir, 0700); // info.names[handle] is @handle's real name // info.tokens[handle] is the token for @handle's avatar info.names = {}; info.tokens = {}; // info.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 = {}; let connName = Telepathy.pathToName(connPath); info.connectionAvatars = new Telepathy.ConnectionAvatars(DBus.session, connName, connPath); info.updatedId = info.connectionAvatars.connect( 'AvatarUpdated', Lang.bind(this, this._avatarUpdated)); info.retrievedId = info.connectionAvatars.connect( 'AvatarRetrieved', Lang.bind(this, this._avatarRetrieved)); info.connectionContacts = new Telepathy.ConnectionContacts(DBus.session, connName, connPath); info.connectionPresence = new Telepathy.ConnectionSimplePresence(DBus.session, connName, connPath); info.presenceChangedId = info.connectionPresence.connect( 'PresencesChanged', Lang.bind(this, this._presencesChanged)); let conn = new Telepathy.Connection(DBus.session, connName, connPath); info.statusChangedId = conn.connect('StatusChanged', Lang.bind(this, function (status, reason) { if (status == Telepathy.ConnectionStatus.DISCONNECTED) this._removeConnection(conn); })); let connReq = new Telepathy.ConnectionRequests(DBus.session, connName, connPath); connReq.EnsureChannelRemote(subscribedContactsChannel, Lang.bind(this, function (result, err) { if (!result) return; let [mine, channelPath, props] = result; this._gotContactsChannel(connPath, channelPath, props); })); this._connections[connPath] = info; return info; }, _gotContactsChannel: function(connPath, channelPath, props) { let info = this._connections[connPath]; if (!info) return; info.contactsGroup = new Telepathy.ChannelGroup(DBus.session, Telepathy.pathToName(connPath), channelPath); info.contactsListChangedId = info.contactsGroup.connect('MembersChanged', Lang.bind(this, this._contactsListChanged, info)); info.contactsGroup.GetRemote('Members', Lang.bind(this, function(contacts, err) { if (!contacts) return; info.connectionContacts.GetContactAttributesRemote( contacts, [Telepathy.CONNECTION_ALIASING_NAME], false, Lang.bind(this, this._gotContactAttributes, info)); })); }, _contactsListChanged: function(group, message, added, removed, local_pending, remote_pending, actor, reason, info) { for (let i = 0; i < removed.length; i++) delete info.names[removed[i]]; info.connectionContacts.GetContactAttributesRemote( added, [Telepathy.CONNECTION_ALIASING_NAME], false, Lang.bind(this, this._gotContactAttributes, info)); }, _gotContactAttributes: function(attrs, err, info) { if (!attrs) return; for (let handle in attrs) info.names[handle] = attrs[handle][Telepathy.CONNECTION_ALIASING_NAME + '/alias']; }, _presencesChanged: function(conn, presences, err) { if (!presences) return; let info = this._connections[conn.getPath()]; if (!info) return; for (let handle in presences) { let [type, status, message] = presences[handle]; this.emit('presence-changed', conn.getPath(), handle, type, message); } }, _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); info.connectionPresence.disconnect(info.presenceChangedId); info.contactsGroup.disconnect(info.contactsListChangedId); delete this._connections[conn.getPath()]; }, _getFileForToken: function(info, token) { return info.cacheDir + '/' + Telepathy.escapeAsIdentifier(token); }, _setIcon: function(iconBox, info, handle) { let textureCache = St.TextureCache.get_default(); let token = info.tokens[handle]; let file; if (token) { file = this._getFileForToken(info, token); if (!GLib.file_test(file, GLib.FileTest.EXISTS)) file = null; } if (file) { let uri = GLib.filename_to_uri(file, null); iconBox.child = textureCache.load_uri_async(uri, iconBox._size, iconBox._size); } else { iconBox.child = textureCache.load_icon_name('stock_person', iconBox._size); } }, _updateIcons: function(info, handle) { if (!info.icons[handle]) return; for (let i = 0; i < info.icons[handle].length; i++) { let iconBox = info.icons[handle][i]; this._setIcon(iconBox, info, handle); } }, _avatarUpdated: function(conn, handle, token) { let info = this._connections[conn.getPath()]; if (!info) return; if (info.tokens[handle] == token) return; info.tokens[handle] = token; if (token != '') { let file = this._getFileForToken(info, token); if (!GLib.file_test(file, GLib.FileTest.EXISTS)) { info.connectionAvatars.RequestAvatarsRemote([handle]); return; } } this._updateIcons(info, handle); }, _avatarRetrieved: function(conn, handle, token, avatarData, mimeType) { let info = this._connections[conn.getPath()]; if (!info) return; let file = this._getFileForToken(info, token); let success = false; try { success = GLib.file_set_contents(file, avatarData, avatarData.length); } catch (e) { logError(e, 'Error caching avatar data'); } if (success) this._updateIcons(info, handle); }, createAvatar: function(conn, handle, size) { let iconBox = new St.Bin({ style_class: 'avatar-box' }); iconBox._size = size; let info = this._connections[conn.getPath()]; if (!info) info = this.addConnection(conn.getPath()); 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); })); // If we already have the icon cached and know its token, this // will fill it in. Otherwise it will fill in the default // icon. this._setIcon(iconBox, info, handle); // Asynchronously load the real avatar if we don't have it yet. if (info.tokens[handle] == null) { info.connectionAvatars.GetKnownAvatarTokensRemote([handle], Lang.bind(this, function (tokens, err) { let token = tokens && tokens[handle] ? tokens[handle] : ''; this._avatarUpdated(conn, handle, token); })); } return iconBox; } }; Signals.addSignalMethods(ContactManager.prototype); function Source(accountPath, connPath, channelPath, targetHandle, targetHandleType, targetId) { this._init(accountPath, connPath, channelPath, targetHandle, targetHandleType, targetId); } Source.prototype = { __proto__: MessageTray.Source.prototype, _init: function(accountPath, connPath, channelPath, targetHandle, targetHandleType, targetId) { MessageTray.Source.prototype._init.call(this, targetId); this._accountPath = accountPath; 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._targetHandleType = targetHandleType; 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]; })); } // Since we only create sources when receiving a message, this // is a plausible default this._presence = Telepathy.ConnectionPresenceType.AVAILABLE; 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 contactManager.createAvatar(this._conn, this._targetHandle, size); }, clicked: function() { channelDispatcher.EnsureChannelRemote(this._accountPath, { 'org.freedesktop.Telepathy.Channel.ChannelType': Telepathy.CHANNEL_TEXT_NAME, 'org.freedesktop.Telepathy.Channel.TargetHandle': this._targetHandle, 'org.freedesktop.Telepathy.Channel.TargetHandleType': this._targetHandleType }, global.get_current_time(), '', Lang.bind(this, this._gotChannelRequest)); MessageTray.Source.prototype.clicked.call(this); }, _gotChannelRequest: function (chanReqPath, ex) { if (ex) { log ('EnsureChannelRemote failed? ' + ex); return; } let chanReq = new Telepathy.ChannelRequest(DBus.session, Telepathy.CHANNEL_DISPATCHER_NAME, chanReqPath); chanReq.ProceedRemote(); }, _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(); }, _ensureNotification: function() { if (!Main.messageTray.contains(this)) Main.messageTray.add(this); if (!this._notification) this._notification = new Notification(this._targetId, this); }, _messageReceived: function(channel, id, timestamp, sender, type, flags, text) { this._ensureNotification(); this._notification.appendMessage(text); this.notify(this._notification); }, respond: function(text) { this._channelText.SendRemote(Telepathy.ChannelTextMessageType.NORMAL, text); }, setPresence: function(presence, message) { let msg, notify; if (presence == Telepathy.ConnectionPresenceType.AVAILABLE) { msg = _("%s is online.").format(this.name); notify = (this._presence == Telepathy.ConnectionPresenceType.OFFLINE); } else if (presence == Telepathy.ConnectionPresenceType.OFFLINE || presence == Telepathy.ConnectionPresenceType.EXTENDED_AWAY) { presence = Telepathy.ConnectionPresenceType.OFFLINE; msg = _("%s is offline.").format(this.name); notify = (this._presence != Telepathy.ConnectionPresenceType.OFFLINE); } else if (presence == Telepathy.ConnectionPresenceType.AWAY) { msg = _("%s is away.").format(this.name); notify = false; } else if (presence == Telepathy.ConnectionPresenceType.BUSY) { msg = _("%s is busy.").format(this.name); notify = false; } else return; this._presence = presence; if (message) msg += ' <i>(' + GLib.markup_escape_text(message, -1) + ')</i>'; this._ensureNotification(); this._notification.appendMessage(msg, true); if (notify) this.notify(this._notification); } }; 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, asTitle) { if (asTitle) this.update(text); else 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(); } } };