[gnome-shell] Add support for chatting directly from IM notifications



commit e94d54bffb32c964266e0c90fd7939268d8a80af
Author: Dan Winship <danw gnome org>
Date:   Mon Feb 22 14:23:36 2010 -0500

    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

 data/theme/gnome-shell.css  |   30 ++++++
 js/misc/telepathy.js        |    4 +
 js/ui/messageTray.js        |  206 +++++++++++++++++++++++++-----------------
 js/ui/notificationDaemon.js |    2 +-
 js/ui/telepathyClient.js    |  129 +++++++++++++++++++++++++--
 5 files changed, 278 insertions(+), 93 deletions(-)
---
diff --git a/data/theme/gnome-shell.css b/data/theme/gnome-shell.css
index 97fd16d..994e63c 100644
--- a/data/theme/gnome-shell.css
+++ b/data/theme/gnome-shell.css
@@ -723,6 +723,18 @@ StTooltip {
     padding-bottom: 38px;
 }
 
+#notification-scrollview {
+    max-height: 10em;
+}
+
+#notification-scrollview > .top-shadow, #notification-scrollview > .bottom-shadow {
+    height: 1em;
+}
+
+#notification-body {
+    spacing: 5px;
+}
+
 #notification-actions {
     spacing: 5px;
 }
@@ -745,6 +757,24 @@ StTooltip {
     background: #808080;
 }
 
+.chat-received {
+    background-gradient-direction: horizontal;
+    background-gradient-start: #606060;
+    background-gradient-end: #000000;
+
+    min-width: 20em;
+}
+
+.chat-sent {
+    background-gradient-direction: horizontal;
+    background-gradient-start: #000000;
+    background-gradient-end: #606060;
+}
+
+.chat-response {
+    border: 1px solid white;
+}
+
 /* The spacing and padding on the summary is tricky; we want to keep
  * the icons from touching each other or the edges of the screen, but
  * we also want them to be "Fitts"-y with respect to the edges, so the
diff --git a/js/misc/telepathy.js b/js/misc/telepathy.js
index d97cfaa..fb809d7 100644
--- a/js/misc/telepathy.js
+++ b/js/misc/telepathy.js
@@ -215,6 +215,10 @@ const ChannelTextIface = {
         { name: 'AcknowledgePendingMessages',
           inSignature: 'au',
           outSignature: ''
+        },
+        { name: 'Send',
+          inSignature: 'us',
+          outSignature: ''
         }
     ],
     signals: [
diff --git a/js/ui/messageTray.js b/js/ui/messageTray.js
index 659312f..921210b 100644
--- a/js/ui/messageTray.js
+++ b/js/ui/messageTray.js
@@ -1,6 +1,7 @@
 /* -*- mode: js2; js2-basic-offset: 4; indent-tabs-mode: nil -*- */
 
 const Clutter = imports.gi.Clutter;
+const Gtk = imports.gi.Gtk;
 const Lang = imports.lang;
 const Mainloop = imports.mainloop;
 const Pango = imports.gi.Pango;
@@ -46,11 +47,15 @@ function _cleanMarkup(text) {
 // @source's icon, @title (in bold) and @banner, all on a single line
 // (with @banner ellipsized if necessary).
 //
-// Additional notification details can be added via addBody(),
-// addAction(), and addActor(). If any of these are called, then the
-// notification will expand to show the additional actors (while
-// hiding the @banner) if the pointer is moved into it while it is
-// visible.
+// Additional notification details can be added, in which case the
+// notification can be expanded by moving the pointer into it. In
+// expanded mode, the banner text disappears, and there can be one or
+// more rows of additional content. This content is put inside a
+// scrollview, so if it gets too tall, the notification will scroll
+// rather than continuing to grow. In addition to this main content
+// area, there is also a single-row "action area", which is not
+// scrolled and can contain a single actor. There are also convenience
+// methods for creating a button box in the action area.
 //
 // If @bannerBody is %true, then @banner will also be used as the body
 // of the notification (as with addBody()) when the banner is expanded.
@@ -71,39 +76,44 @@ Notification.prototype = {
                 this.emit('dismissed');
             }));
 
-        this.actor = new St.Table({ name: 'notification' });
+        this.actor = new St.Table({ name: 'notification',
+                                    reactive: true });
         this.update(title, banner, true);
     },
 
     // update:
     // @title: the new title
     // @banner: the new banner
-    // @clear: whether or not to clear out extra actors
+    // @clear: whether or not to clear out body and action actors
     //
     // Updates the notification by regenerating its icon and updating
     // the title/banner. If @clear is %true, it will also remove any
     // additional actors/action buttons previously added.
     update: function(title, banner, clear) {
-        let children = this.actor.get_children();
-        for (let i = 0; i < children.length; i++) {
-            let meta = this.actor.get_child_meta(children[i]);
-            if (clear || meta.row == 0 || (this._bannerBody && meta.row == 1))
-                children[i].destroy();
+        if (this._icon)
+            this._icon.destroy();
+        if (this._bannerBox)
+            this._bannerBox.destroy();
+        if (this._scrollArea && (this._bannerBody || clear)) {
+            this._scrollArea.destroy();
+            this._scrollArea = null;
+            this._contentArea = null;
         }
-        if (clear) {
-            this.actions = {};
-            this._actionBox = null;
+        if (this._actionArea && clear) {
+            this._actionArea.destroy();
+            this._actionArea = null;
+            this._buttonBox = null;
         }
 
-        let icon = this.source.createIcon(ICON_SIZE);
-        icon.reactive = true;
-        this.actor.add(icon, { row: 0,
-                               col: 0,
-                               x_expand: false,
-                               y_expand: false,
-                               y_fill: false });
+        this._icon = this.source.createIcon(ICON_SIZE);
+        this._icon.reactive = true;
+        this.actor.add(this._icon, { row: 0,
+                                     col: 0,
+                                     x_expand: false,
+                                     y_expand: false,
+                                     y_fill: false });
 
-        icon.connect('button-release-event', Lang.bind(this,
+        this._icon.connect('button-release-event', Lang.bind(this,
             function () {
                 this.source.clicked();
             }));
@@ -139,64 +149,32 @@ Notification.prototype = {
     },
 
     // addActor:
-    // @actor: actor to add to the notification
-    // @props: (optional) child properties
+    // @actor: actor to add to the body of the notification
     //
-    // Adds @actor to the notification's St.Table, using @props.
-    //
-    // If @props does not specify a %row, then @actor will be added
-    // to the bottom of the notification (unless there are action
-    // buttons present, in which case it will be added above them).
-    //
-    // If @props does not specify a %col, it will default to column 1.
-    // (Normally only the icon is in column 0.)
-    //
-    // If @props specifies an already-occupied cell, then the existing
-    // contents of the table will be shifted down to make room for it.
-    addActor: function(actor, props) {
-        if (!props)
-            props = {};
-
-        if (!('col' in props))
-            props.col = 1;
-
-        if ('row' in props) {
-            let children = this.actor.get_children();
-            let i, meta, collision = false;
-
-            for (i = 0; i < children.length; i++) {
-                meta = this.actor.get_child_meta(children[i]);
-                if (meta.row == props.row && meta.col == props.col) {
-                    collision = true;
-                    break;
-                }
-            }
-
-            if (collision) {
-                for (i = 0; i < children.length; i++) {
-                    meta = this.actor.get_child_meta(children[i]);
-                    if (meta.row >= props.row)
-                        meta.row++;
-                }
-            }
-        } else {
-            if (this._actionBox) {
-                props.row = this.actor.row_count - 1;
-                this.actor.get_child_meta(this._actionBox).row++;
-            } else {
-                props.row = this.actor.row_count;
-            }
+    // Appends @actor to the notification's body
+    addActor: function(actor) {
+        if (!this._scrollArea) {
+            this._scrollArea = new St.ScrollView({ name: 'notification-scrollview',
+                                                   vscrollbar_policy: Gtk.PolicyType.AUTOMATIC,
+                                                   hscrollbar_policy: Gtk.PolicyType.NEVER,
+                                                   vshadows: true });
+            this.actor.add(this._scrollArea, { row: 1,
+                                               col: 1 });
+            this._contentArea = new St.BoxLayout({ name: 'notification-body',
+                                                   vertical: true });
+            this._scrollArea.add_actor(this._contentArea);
         }
 
-        this.actor.add(actor, props);
+        this._contentArea.add(actor);
     },
 
     // addBody:
     // @text: the text
-    // @props: (optional) properties for addActor()
     //
     // Adds a multi-line label containing @text to the notification.
-    addBody: function(text, props) {
+    //
+    // Return value: the newly-added label
+    addBody: function(text) {
         let body = new St.Label();
         body.clutter_text.line_wrap = true;
         body.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
@@ -205,15 +183,56 @@ Notification.prototype = {
         text = text ? _cleanMarkup(text) : '';
         body.clutter_text.set_markup(text);
 
-        this.addActor(body, props);
+        this.addActor(body);
+        return body;
     },
 
     _addBannerBody: function() {
-        this.addBody(this._bannerBodyText, { row: 1 });
+        this.addBody(this._bannerBodyText);
         this._bannerBodyText = null;
     },
 
-    // addAction:
+    // scrollTo:
+    // @side: St.Side.TOP or St.Side.BOTTOM
+    //
+    // Scrolls the content area (if scrollable) to the indicated edge
+    scrollTo: function(side) {
+        // Hack to force a relayout, since the caller probably
+        // just added or removed something to scrollArea, and
+        // the adjustment needs to reflect that.
+        global.stage.get_actor_at_pos(Clutter.PickMode.REACTIVE, 0, 0);
+
+        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;
+    },
+
+    // setActionArea:
+    // @actor: the actor
+    // @props: (option) St.Table child properties
+    //
+    // Puts @actor into the action area of the notification, replacing
+    // the previous contents
+    setActionArea: function(actor, props) {
+        if (this._actionArea) {
+            this._actionArea.destroy();
+            this._actionArea = null;
+            if (this._buttonBox)
+                this._buttonBox = null;
+        }
+        this._actionArea = actor;
+
+        if (!props)
+            props = {};
+        props.row = 2;
+        props.col = 1;
+
+        this.actor.add(this._actionArea, props);
+    },
+
+    // addButton:
     // @id: the action ID
     // @label: the label for the action's button
     //
@@ -223,21 +242,21 @@ Notification.prototype = {
     //
     // If the button is clicked, the notification will emit the
     // %action-invoked signal with @id as a parameter
-    addAction: function(id, label) {
-        if (!this._actionBox) {
+    addButton: function(id, label) {
+        if (!this._buttonBox) {
             if (this._bannerBodyText)
                 this._addBannerBody();
 
             let box = new St.BoxLayout({ name: 'notification-actions' });
-            this.addActor(box, { x_expand: false,
-                                 x_fill: false,
-                                 x_align: St.Align.END });
-            this._actionBox = box;
+            this.setActionArea(box, { x_expand: false,
+                                      x_fill: false,
+                                      x_align: St.Align.END });
+            this._buttonBox = box;
         }
 
         let button = new St.Button({ style_class: 'notification-button',
                                      label: label });
-        this._actionBox.add(button);
+        this._buttonBox.add(button);
         button.connect('clicked', Lang.bind(this, function() { this.emit('action-invoked', id); }));
     },
 
@@ -563,6 +582,19 @@ MessageTray.prototype = {
         return null;
     },
 
+    lock: function() {
+        this._locked = true;
+    },
+
+    unlock: function() {
+        this._locked = false;
+
+        this.actor.sync_hover();
+        this._summary.sync_hover();
+
+        this._updateState();
+    },
+
     _onNotify: function(source, notification) {
         if (this._getNotification(notification.id, source) == null) {
             notification.connect('destroy',
@@ -647,7 +679,7 @@ MessageTray.prototype = {
         let notificationsPending = this._notificationQueue.length > 0;
         let notificationPinned = this._pointerInTray && !this._pointerInSummary && !this._notificationRemoved;
         let notificationExpanded = this._notificationBin.y < 0;
-        let notificationExpired = (this._notificationTimeoutId == 0 && !this._pointerInTray) || this._notificationRemoved;
+        let notificationExpired = (this._notificationTimeoutId == 0 && !this._pointerInTray && !this._locked) || this._notificationRemoved;
 
         if (this._notificationState == State.HIDDEN) {
             if (notificationsPending)
@@ -778,6 +810,11 @@ MessageTray.prototype = {
     _hideNotification: function() {
         this._notification.popIn();
 
+        if (this._reExpandNotificationId) {
+            this._notificationBin.disconnect(this._reExpandNotificationId);
+            this._reExpandNotificationId = 0;
+        }
+
         this._tween(this._notificationBin, "_notificationState", State.HIDDEN,
                     { y: this.actor.height,
                       opacity: 0,
@@ -802,6 +839,9 @@ MessageTray.prototype = {
                           time: ANIMATION_TIME,
                           transition: "easeOutQuad"
                         });
+
+            if (!this._reExpandNotificationId)
+                this._reExpandNotificationId = this._notificationBin.connect('notify::height', Lang.bind(this, this._expandNotification));
         }
     },
 
diff --git a/js/ui/notificationDaemon.js b/js/ui/notificationDaemon.js
index 2de216c..1bd57c3 100644
--- a/js/ui/notificationDaemon.js
+++ b/js/ui/notificationDaemon.js
@@ -207,7 +207,7 @@ NotificationDaemon.prototype = {
 
         if (actions.length) {
             for (let i = 0; i < actions.length - 1; i += 2)
-                notification.addAction(actions[i], actions[i + 1]);
+                notification.addButton(actions[i], actions[i + 1]);
             notification.connect('action-invoked', Lang.bind(this, this._actionInvoked, source, id));
         }
 
diff --git a/js/ui/telepathyClient.js b/js/ui/telepathyClient.js
index 7de1995..7434c35 100644
--- a/js/ui/telepathyClient.js
+++ b/js/ui/telepathyClient.js
@@ -1,5 +1,6 @@
 /* -*- 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;
@@ -11,6 +12,11 @@ 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",
@@ -316,7 +322,7 @@ Source.prototype = {
         }
 
         this._channelText = new Telepathy.ChannelText(DBus.session, connName, channelPath);
-        this._receivedId = this._channelText.connect('Received', Lang.bind(this, this._receivedMessage));
+        this._receivedId = this._channelText.connect('Received', Lang.bind(this, this._messageReceived));
 
         this._channelText.ListPendingMessagesRemote(false, Lang.bind(this, this._gotPendingMessages));
     },
@@ -330,7 +336,7 @@ Source.prototype = {
             return;
 
         for (let i = 0; i < msgs.length; i++)
-            this._receivedMessage.apply(this, [this._channel].concat(msgs[i]));
+            this._messageReceived.apply(this, [this._channel].concat(msgs[i]));
     },
 
     _channelClosed: function() {
@@ -339,26 +345,131 @@ Source.prototype = {
         this.destroy();
     },
 
-    _receivedMessage: function(channel, id, timestamp, sender,
+    _messageReceived: function(channel, id, timestamp, sender,
                                type, flags, text) {
         if (!Main.messageTray.contains(this))
             Main.messageTray.add(this);
 
-        let notification = new Notification(this._targetId, this, text);
-        this.notify(notification);
+        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, text) {
-    this._init(id, source, text);
+function Notification(id, source) {
+    this._init(id, source);
 }
 
 Notification.prototype = {
     __proto__:  MessageTray.Notification.prototype,
 
-    _init: function(id, source, text) {
-        MessageTray.Notification.prototype._init.call(this, id, source, source.name, text, true);
+    _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();
+        }
     }
 };



[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]