diff --git a/js/ui/messageTray.js b/js/ui/messageTray.js index b1495031fc..cc1b28fcbc 100644 --- a/js/ui/messageTray.js +++ b/js/ui/messageTray.js @@ -260,6 +260,10 @@ var Notification = class Notification { this._titleDirection = St.TextDirection.NONE; this._scrollArea = null; this._actionArea = null; + this._replyArea = null; + this._replyEntry = null; + this.hasInlineReply = false; + this.inlineReplyAction = null; this._imageBin = null; this._timestamp = new Date(); this._inNotificationBin = false; @@ -464,7 +468,7 @@ var Notification = class Notification { } _updateLayout() { - if (this._imageBin || this._scrollArea || this._actionArea) { + if (this._imageBin || this._scrollArea || this._actionArea || this._replyArea) { this._table.add_style_class_name('multi-line-notification'); } else { this._table.remove_style_class_name('multi-line-notification'); @@ -486,6 +490,11 @@ var Notification = class Notification { col: this._imageBin ? 2 : 1, col_span: this._imageBin ? 2 : 3 }); + if (this._replyArea) + this._table.child_set(this._replyArea, { + col: this._imageBin ? 2 : 1, + col_span: this._imageBin ? 2 : 3 + }); } setImage(image) { @@ -565,6 +574,37 @@ var Notification = class Notification { this._updateLayout(); } + addReplyButton(id, label) { + if (!this._actionArea) { + this._actionArea = new St.BoxLayout({ name: 'notification-actions' }); + this._table.add(this._actionArea, { + row: 2, + col: 1, + col_span: 3, + x_expand: true, + y_expand: false, + x_fill: true, + y_fill: false, + x_align: St.Align.START + }); + } + + let button = new St.Button({ can_focus: true, style_class: 'notification-button', label: label }); + + if (this._actionArea.get_n_children() > 0) + this._buttonFocusManager.remove_group(this._actionArea); + + this._actionArea.add(button); + this._buttonFocusManager.add_group(this._actionArea); + + button.connect('clicked', () => { + this._ensureReplyArea(); + button.destroy(); + }); + + this._updateLayout(); + } + /** * clearButtons: * @@ -578,6 +618,122 @@ var Notification = class Notification { this._updateLayout(); } + setInlineReply(hasInlineReply, actionId, expandImmediately = false) { + this.hasInlineReply = hasInlineReply; + this.inlineReplyAction = actionId; + if (hasInlineReply && expandImmediately) { + this._ensureReplyArea(); + } else if (!hasInlineReply && this._replyArea) { + this._replyArea.destroy(); + this._replyArea = null; + this._replyEntry = null; + this._updateLayout(); + } + } + + _ensureReplyArea() { + if (this._replyArea) + return; + + this._replyArea = new St.BoxLayout({ + name: 'notification-reply-box', + style_class: 'notification-reply-box', + x_expand: true, + y_expand: false, + reactive: true + }); + + this._table.add(this._replyArea, { + row: 3, + col: 1, + col_span: 3, + x_expand: true, + y_expand: false, + x_fill: true, + y_fill: false, + x_align: St.Align.START + }); + + this._replyEntry = new St.Entry({ + can_focus: true, + hint_text: _("Type a reply..."), + style_class: 'notification-entry', + x_expand: true, + reactive: true + }); + + let sendButton = new St.Button({ + label: _("Send"), + can_focus: true, + style_class: 'notification-button', + reactive: true + }); + + let isModal = false; + + let releaseModal = () => { + if (isModal) { + Main.popModal(this._replyEntry.clutter_text); + isModal = false; + } + }; + + let grabFocus = () => { + if (!isModal) { + isModal = Main.pushModal(this._replyEntry.clutter_text); + } + if (isModal) { + this._replyEntry.clutter_text.grab_key_focus(); + global.stage.set_key_focus(this._replyEntry.clutter_text); + } + return true; + }; + + let onSend = () => { + let text = this._replyEntry.get_text(); + if (text && text.trim().length > 0) { + releaseModal(); + this.emit('reply', text); + if (this.inlineReplyAction && typeof this.inlineReplyAction === 'string') { + this.emit('action-invoked', this.inlineReplyAction); + } + if (!this.resident) { + this.emit('done-displaying'); + this.destroy(); + } + } + }; + + sendButton.connect('clicked', onSend); + + this._replyEntry.clutter_text.connect('activate', onSend); + + this._replyEntry.connect('button-press-event', grabFocus); + this._replyEntry.clutter_text.connect('button-press-event', grabFocus); + this._replyArea.connect('button-press-event', grabFocus); + + this.connect('destroy', () => { + releaseModal(); + }); + + this._replyArea.add(this._replyEntry, { expand: true }); + this._replyArea.add(sendButton, { expand: false }); + + if (this._buttonFocusManager) { + this._buttonFocusManager.add_group(this._replyArea); + } + + // Auto focus the entry when reply area is created + Mainloop.idle_add(() => { + if (this._replyEntry && this._replyEntry.clutter_text && this._replyEntry.get_stage()) { + grabFocus(); + } + return false; + }); + + this._updateLayout(); + } + setUrgency(urgency) { this.urgency = urgency; } diff --git a/js/ui/notificationDaemon.js b/js/ui/notificationDaemon.js index 7c01633a5c..a8e6ac9f31 100644 --- a/js/ui/notificationDaemon.js +++ b/js/ui/notificationDaemon.js @@ -65,6 +65,10 @@ const NotificationDaemonIface = \ \ \ + \ + \ + \ + \ \ '; @@ -474,6 +478,9 @@ NotificationDaemon.prototype = { function(n, actionId) { this._emitActionInvoked(ndata.id, actionId); })); + notification.connect('reply', (n, text) => { + this._emitNotificationReplied(ndata.id, text); + }); } else { notification.update(summary, body, { icon: iconActor, bodyMarkup: true, @@ -509,6 +516,9 @@ NotificationDaemon.prototype = { notification.clearButtons(); + let inlineReplyHint = hints.maybeGet('x-canonical-private-inline-reply') || hints.maybeGet('inline-reply'); + let hasReplyAction = false; + if (actions.length) { notification.setUseActionIcons(hints.maybeGet('action-icons') == true); for (let i = 0; i < actions.length - 1; i += 2) { @@ -517,7 +527,10 @@ NotificationDaemon.prototype = { function() { this._emitActionInvoked(ndata.id, "default"); })); - else + else if (inlineReplyHint && actions[i] === inlineReplyHint) { + notification.addReplyButton(actions[i], actions[i + 1]); + hasReplyAction = true; + } else notification.addButton(actions[i], actions[i + 1]); } } @@ -537,6 +550,12 @@ NotificationDaemon.prototype = { // of the 'transient' hint with hints['transient'] rather than hints.transient notification.setTransient(hints.maybeGet('transient') == true); + if (inlineReplyHint != null) { + notification.setInlineReply(true, inlineReplyHint, !hasReplyAction); + } else { + notification.setInlineReply(false, null, false); + } + let sourceIconActor = source.useNotificationIcon ? this._iconForNotificationData(appIcon, hints, source.ICON_SIZE) : null; source.processNotification(notification, sourceIconActor); }, @@ -562,6 +581,8 @@ NotificationDaemon.prototype = { 'icon-static', 'persistence', 'sound', + 'inline-reply', + 'x-canonical-private-inline-reply', ]; }, @@ -603,6 +624,11 @@ NotificationDaemon.prototype = { GLib.Variant.new('(us)', [id, action])); }, + _emitNotificationReplied: function(id, text) { + this._dbusImpl.emit_signal('NotificationReplied', + GLib.Variant.new('(us)', [id, text])); + }, + _onTrayIconAdded: function(o, icon) { let source = this._getSource(icon.title || icon.wm_class || _("Unknown"), icon.pid, null, null, icon); },