From e94d54bffb32c964266e0c90fd7939268d8a80af Mon Sep 17 00:00:00 2001 From: Dan Winship Date: Mon, 22 Feb 2010 14:23:36 -0500 Subject: [PATCH] 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 97fd16d50..994e63c95 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 d97cfaa35..fb809d720 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 659312f0b..921210b1f 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 2de216cb6..1bd57c333 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 7de1995b7..7434c354e 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(); + } } };