summaryrefslogtreecommitdiffstats
path: root/js/ui/messageTray.js
diff options
context:
space:
mode:
Diffstat (limited to 'js/ui/messageTray.js')
-rw-r--r--js/ui/messageTray.js1423
1 files changed, 1423 insertions, 0 deletions
diff --git a/js/ui/messageTray.js b/js/ui/messageTray.js
new file mode 100644
index 0000000..1edd932
--- /dev/null
+++ b/js/ui/messageTray.js
@@ -0,0 +1,1423 @@
+// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
+/* exported NotificationPolicy, NotificationGenericPolicy,
+ NotificationApplicationPolicy, Source, SourceActor,
+ SystemNotificationSource, MessageTray */
+
+const { Clutter, Gio, GLib, GObject, Meta, Shell, St } = imports.gi;
+
+const Calendar = imports.ui.calendar;
+const GnomeSession = imports.misc.gnomeSession;
+const Layout = imports.ui.layout;
+const Main = imports.ui.main;
+const Params = imports.misc.params;
+const SignalTracker = imports.misc.signalTracker;
+
+const SHELL_KEYBINDINGS_SCHEMA = 'org.gnome.shell.keybindings';
+
+var ANIMATION_TIME = 200;
+var NOTIFICATION_TIMEOUT = 4000;
+
+var HIDE_TIMEOUT = 200;
+var LONGER_HIDE_TIMEOUT = 600;
+
+var MAX_NOTIFICATIONS_IN_QUEUE = 3;
+var MAX_NOTIFICATIONS_PER_SOURCE = 3;
+var MAX_NOTIFICATION_BUTTONS = 3;
+
+// We delay hiding of the tray if the mouse is within MOUSE_LEFT_ACTOR_THRESHOLD
+// range from the point where it left the tray.
+var MOUSE_LEFT_ACTOR_THRESHOLD = 20;
+
+var IDLE_TIME = 1000;
+
+var State = {
+ HIDDEN: 0,
+ SHOWING: 1,
+ SHOWN: 2,
+ HIDING: 3,
+};
+
+// These reasons are useful when we destroy the notifications received through
+// the notification daemon. We use EXPIRED for notifications that we dismiss
+// and the user did not interact with, DISMISSED for all other notifications
+// that were destroyed as a result of a user action, SOURCE_CLOSED for the
+// notifications that were requested to be destroyed by the associated source,
+// and REPLACED for notifications that were destroyed as a consequence of a
+// newer version having replaced them.
+var NotificationDestroyedReason = {
+ EXPIRED: 1,
+ DISMISSED: 2,
+ SOURCE_CLOSED: 3,
+ REPLACED: 4,
+};
+
+// Message tray has its custom Urgency enumeration. LOW, NORMAL and CRITICAL
+// urgency values map to the corresponding values for the notifications received
+// through the notification daemon. HIGH urgency value is used for chats received
+// through the Telepathy client.
+var Urgency = {
+ LOW: 0,
+ NORMAL: 1,
+ HIGH: 2,
+ CRITICAL: 3,
+};
+
+// The privacy of the details of a notification. USER is for notifications which
+// contain private information to the originating user account (for example,
+// details of an e-mail they’ve received). SYSTEM is for notifications which
+// contain information private to the physical system (for example, battery
+// status) and hence the same for every user. This affects whether the content
+// of a notification is shown on the lock screen.
+var PrivacyScope = {
+ USER: 0,
+ SYSTEM: 1,
+};
+
+var FocusGrabber = class FocusGrabber {
+ constructor(actor) {
+ this._actor = actor;
+ this._prevKeyFocusActor = null;
+ this._focused = false;
+ }
+
+ grabFocus() {
+ if (this._focused)
+ return;
+
+ this._prevKeyFocusActor = global.stage.get_key_focus();
+
+ global.stage.connectObject('notify::key-focus',
+ this._focusActorChanged.bind(this), this);
+
+ if (!this._actor.navigate_focus(null, St.DirectionType.TAB_FORWARD, false))
+ this._actor.grab_key_focus();
+
+ this._focused = true;
+ }
+
+ _focusUngrabbed() {
+ if (!this._focused)
+ return false;
+
+ global.stage.disconnectObject(this);
+
+ this._focused = false;
+ return true;
+ }
+
+ _focusActorChanged() {
+ let focusedActor = global.stage.get_key_focus();
+ if (!focusedActor || !this._actor.contains(focusedActor))
+ this._focusUngrabbed();
+ }
+
+ ungrabFocus() {
+ if (!this._focusUngrabbed())
+ return;
+
+ if (this._prevKeyFocusActor) {
+ global.stage.set_key_focus(this._prevKeyFocusActor);
+ this._prevKeyFocusActor = null;
+ } else {
+ let focusedActor = global.stage.get_key_focus();
+ if (focusedActor && this._actor.contains(focusedActor))
+ global.stage.set_key_focus(null);
+ }
+ }
+};
+
+// NotificationPolicy:
+// An object that holds all bits of configurable policy related to a notification
+// source, such as whether to play sound or honour the critical bit.
+//
+// A notification without a policy object will inherit the default one.
+var NotificationPolicy = GObject.registerClass({
+ GTypeFlags: GObject.TypeFlags.ABSTRACT,
+ Properties: {
+ 'enable': GObject.ParamSpec.boolean(
+ 'enable', 'enable', 'enable', GObject.ParamFlags.READABLE, true),
+ 'enable-sound': GObject.ParamSpec.boolean(
+ 'enable-sound', 'enable-sound', 'enable-sound',
+ GObject.ParamFlags.READABLE, true),
+ 'show-banners': GObject.ParamSpec.boolean(
+ 'show-banners', 'show-banners', 'show-banners',
+ GObject.ParamFlags.READABLE, true),
+ 'force-expanded': GObject.ParamSpec.boolean(
+ 'force-expanded', 'force-expanded', 'force-expanded',
+ GObject.ParamFlags.READABLE, false),
+ 'show-in-lock-screen': GObject.ParamSpec.boolean(
+ 'show-in-lock-screen', 'show-in-lock-screen', 'show-in-lock-screen',
+ GObject.ParamFlags.READABLE, false),
+ 'details-in-lock-screen': GObject.ParamSpec.boolean(
+ 'details-in-lock-screen', 'details-in-lock-screen', 'details-in-lock-screen',
+ GObject.ParamFlags.READABLE, false),
+ },
+}, class NotificationPolicy extends GObject.Object {
+ // Do nothing for the default policy. These methods are only useful for the
+ // GSettings policy.
+ store() { }
+
+ destroy() {
+ this.run_dispose();
+ }
+
+ get enable() {
+ return true;
+ }
+
+ get enableSound() {
+ return true;
+ }
+
+ get showBanners() {
+ return true;
+ }
+
+ get forceExpanded() {
+ return false;
+ }
+
+ get showInLockScreen() {
+ return false;
+ }
+
+ get detailsInLockScreen() {
+ return false;
+ }
+});
+
+var NotificationGenericPolicy = GObject.registerClass({
+}, class NotificationGenericPolicy extends NotificationPolicy {
+ _init() {
+ super._init();
+ this.id = 'generic';
+
+ this._masterSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications' });
+ this._masterSettings.connect('changed', this._changed.bind(this));
+ }
+
+ destroy() {
+ this._masterSettings.run_dispose();
+
+ super.destroy();
+ }
+
+ _changed(settings, key) {
+ if (this.constructor.find_property(key))
+ this.notify(key);
+ }
+
+ get showBanners() {
+ return this._masterSettings.get_boolean('show-banners');
+ }
+
+ get showInLockScreen() {
+ return this._masterSettings.get_boolean('show-in-lock-screen');
+ }
+});
+
+var NotificationApplicationPolicy = GObject.registerClass({
+}, class NotificationApplicationPolicy extends NotificationPolicy {
+ _init(id) {
+ super._init();
+
+ this.id = id;
+ this._canonicalId = this._canonicalizeId(id);
+
+ this._masterSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.notifications' });
+ this._settings = new Gio.Settings({
+ schema_id: 'org.gnome.desktop.notifications.application',
+ path: `/org/gnome/desktop/notifications/application/${this._canonicalId}/`,
+ });
+
+ this._masterSettings.connect('changed', this._changed.bind(this));
+ this._settings.connect('changed', this._changed.bind(this));
+ }
+
+ store() {
+ this._settings.set_string('application-id', `${this.id}.desktop`);
+
+ let apps = this._masterSettings.get_strv('application-children');
+ if (!apps.includes(this._canonicalId)) {
+ apps.push(this._canonicalId);
+ this._masterSettings.set_strv('application-children', apps);
+ }
+ }
+
+ destroy() {
+ this._masterSettings.run_dispose();
+ this._settings.run_dispose();
+
+ super.destroy();
+ }
+
+ _changed(settings, key) {
+ if (this.constructor.find_property(key))
+ this.notify(key);
+ }
+
+ _canonicalizeId(id) {
+ // Keys are restricted to lowercase alphanumeric characters and dash,
+ // and two dashes cannot be in succession
+ return id.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/--+/g, '-');
+ }
+
+ get enable() {
+ return this._settings.get_boolean('enable');
+ }
+
+ get enableSound() {
+ return this._settings.get_boolean('enable-sound-alerts');
+ }
+
+ get showBanners() {
+ return this._masterSettings.get_boolean('show-banners') &&
+ this._settings.get_boolean('show-banners');
+ }
+
+ get forceExpanded() {
+ return this._settings.get_boolean('force-expanded');
+ }
+
+ get showInLockScreen() {
+ return this._masterSettings.get_boolean('show-in-lock-screen') &&
+ this._settings.get_boolean('show-in-lock-screen');
+ }
+
+ get detailsInLockScreen() {
+ return this._settings.get_boolean('details-in-lock-screen');
+ }
+});
+
+// Notification:
+// @source: the notification's Source
+// @title: the title
+// @banner: the banner text
+// @params: optional additional params
+//
+// Creates a notification. In the banner mode, the notification
+// will show an icon, @title (in bold) and @banner, all on a single
+// line (with @banner ellipsized if necessary).
+//
+// The notification will be expandable if either it has additional
+// elements that were added to it or if the @banner text did not
+// fit fully in the banner mode. When the notification is expanded,
+// the @banner text from the top line is always removed. The complete
+// @banner text is added as the first element in the content section,
+// unless 'customContent' parameter with the value 'true' is specified
+// in @params.
+//
+// Additional notification content can be added with addActor() and
+// addBody() methods. The notification content is put inside a
+// scrollview, so if it gets too tall, the notification will scroll
+// rather than continue 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. The action area can
+// be set by calling setActionArea() method. There is also a
+// convenience method addButton() for adding a button to the action
+// area.
+//
+// If @params contains a 'customContent' parameter with the value %true,
+// then @banner will not be shown in the body of the notification when the
+// notification is expanded and calls to update() will not clear the content
+// unless 'clear' parameter with value %true is explicitly specified.
+//
+// By default, the icon shown is the same as the source's.
+// However, if @params contains a 'gicon' parameter, the passed in gicon
+// will be used.
+//
+// You can add a secondary icon to the banner with 'secondaryGIcon'. There
+// is no fallback for this icon.
+//
+// If @params contains 'bannerMarkup', with the value %true, a subset (<b>,
+// <i> and <u>) of the markup in [1] will be interpreted within @banner. If
+// the parameter is not present, then anything that looks like markup
+// in @banner will appear literally in the output.
+//
+// If @params contains a 'clear' parameter with the value %true, then
+// the content and the action area of the notification will be cleared.
+// The content area is also always cleared if 'customContent' is false
+// because it might contain the @banner that didn't fit in the banner mode.
+//
+// If @params contains 'soundName' or 'soundFile', the corresponding
+// event sound is played when the notification is shown (if the policy for
+// @source allows playing sounds).
+//
+// [1] https://developer.gnome.org/notification-spec/#markup
+var Notification = GObject.registerClass({
+ Properties: {
+ 'acknowledged': GObject.ParamSpec.boolean(
+ 'acknowledged', 'acknowledged', 'acknowledged',
+ GObject.ParamFlags.READWRITE,
+ false),
+ },
+ Signals: {
+ 'activated': {},
+ 'destroy': { param_types: [GObject.TYPE_UINT] },
+ 'updated': { param_types: [GObject.TYPE_BOOLEAN] },
+ },
+}, class Notification extends GObject.Object {
+ _init(source, title, banner, params) {
+ super._init();
+
+ this.source = source;
+ this.title = title;
+ this.urgency = Urgency.NORMAL;
+ // 'transient' is a reserved keyword in JS, so we have to use an alternate variable name
+ this.isTransient = false;
+ this.privacyScope = PrivacyScope.USER;
+ this.forFeedback = false;
+ this.bannerBodyText = null;
+ this.bannerBodyMarkup = false;
+ this._soundName = null;
+ this._soundFile = null;
+ this._soundPlayed = false;
+ this.actions = [];
+ this.setResident(false);
+
+ // If called with only one argument we assume the caller
+ // will call .update() later on. This is the case of
+ // NotificationDaemon, which wants to use the same code
+ // for new and updated notifications
+ if (arguments.length != 1)
+ this.update(title, banner, params);
+ }
+
+ // update:
+ // @title: the new title
+ // @banner: the new banner
+ // @params: as in the Notification constructor
+ //
+ // Updates the notification by regenerating its icon and updating
+ // the title/banner. If @params.clear is %true, it will also
+ // remove any additional actors/action buttons previously added.
+ update(title, banner, params) {
+ params = Params.parse(params, {
+ gicon: null,
+ secondaryGIcon: null,
+ bannerMarkup: false,
+ clear: false,
+ datetime: null,
+ soundName: null,
+ soundFile: null,
+ });
+
+ this.title = title;
+ this.bannerBodyText = banner;
+ this.bannerBodyMarkup = params.bannerMarkup;
+
+ if (params.datetime)
+ this.datetime = params.datetime;
+ else
+ this.datetime = GLib.DateTime.new_now_local();
+
+ if (params.gicon || params.clear)
+ this.gicon = params.gicon;
+
+ if (params.secondaryGIcon || params.clear)
+ this.secondaryGIcon = params.secondaryGIcon;
+
+ if (params.clear)
+ this.actions = [];
+
+ if (this._soundName != params.soundName ||
+ this._soundFile != params.soundFile) {
+ this._soundName = params.soundName;
+ this._soundFile = params.soundFile;
+ this._soundPlayed = false;
+ }
+
+ this.emit('updated', params.clear);
+ }
+
+ // addAction:
+ // @label: the label for the action's button
+ // @callback: the callback for the action
+ addAction(label, callback) {
+ this.actions.push({ label, callback });
+ }
+
+ setUrgency(urgency) {
+ this.urgency = urgency;
+ }
+
+ setResident(resident) {
+ this.resident = resident;
+ }
+
+ setTransient(isTransient) {
+ this.isTransient = isTransient;
+ }
+
+ setForFeedback(forFeedback) {
+ this.forFeedback = forFeedback;
+ }
+
+ setPrivacyScope(privacyScope) {
+ this.privacyScope = privacyScope;
+ }
+
+ playSound() {
+ if (this._soundPlayed)
+ return;
+
+ if (!this.source.policy.enableSound) {
+ this._soundPlayed = true;
+ return;
+ }
+
+ let player = global.display.get_sound_player();
+ if (this._soundName)
+ player.play_from_theme(this._soundName, this.title, null);
+ else if (this._soundFile)
+ player.play_from_file(this._soundFile, this.title, null);
+ }
+
+ // Allow customizing the banner UI:
+ // the default implementation defers the creation to
+ // the source (which will create a NotificationBanner),
+ // so customization can be done by subclassing either
+ // Notification or Source
+ createBanner() {
+ return this.source.createBanner(this);
+ }
+
+ activate() {
+ this.emit('activated');
+
+ if (!this.resident)
+ this.destroy();
+ }
+
+ destroy(reason = NotificationDestroyedReason.DISMISSED) {
+ this.emit('destroy', reason);
+ this.run_dispose();
+ }
+});
+SignalTracker.registerDestroyableType(Notification);
+
+var NotificationBanner = GObject.registerClass({
+ Signals: {
+ 'done-displaying': {},
+ 'unfocused': {},
+ },
+}, class NotificationBanner extends Calendar.NotificationMessage {
+ _init(notification) {
+ super._init(notification);
+
+ this.can_focus = false;
+ this.add_style_class_name('notification-banner');
+
+ this._buttonBox = null;
+
+ this._addActions();
+ this._addSecondaryIcon();
+
+ this.notification.connectObject('activated', () => {
+ // We hide all types of notifications once the user clicks on
+ // them because the common outcome of clicking should be the
+ // relevant window being brought forward and the user's
+ // attention switching to the window.
+ this.emit('done-displaying');
+ }, this);
+ }
+
+ _onUpdated(n, clear) {
+ super._onUpdated(n, clear);
+
+ if (clear) {
+ this.setSecondaryActor(null);
+ this.setActionArea(null);
+ this._buttonBox = null;
+ }
+
+ this._addActions();
+ this._addSecondaryIcon();
+ }
+
+ _addActions() {
+ this.notification.actions.forEach(action => {
+ this.addAction(action.label, action.callback);
+ });
+ }
+
+ _addSecondaryIcon() {
+ if (this.notification.secondaryGIcon) {
+ const icon = new St.Icon({
+ gicon: this.notification.secondaryGIcon,
+ x_align: Clutter.ActorAlign.END,
+ });
+ this.setSecondaryActor(icon);
+ }
+ }
+
+ addButton(button, callback) {
+ if (!this._buttonBox) {
+ this._buttonBox = new St.BoxLayout({
+ style_class: 'notification-actions',
+ x_expand: true,
+ });
+ this.setActionArea(this._buttonBox);
+ global.focus_manager.add_group(this._buttonBox);
+ }
+
+ if (this._buttonBox.get_n_children() >= MAX_NOTIFICATION_BUTTONS)
+ return null;
+
+ this._buttonBox.add(button);
+ button.connect('clicked', () => {
+ callback();
+
+ if (!this.notification.resident) {
+ // We don't hide a resident notification when the user invokes one of its actions,
+ // because it is common for such notifications to update themselves with new
+ // information based on the action. We'd like to display the updated information
+ // in place, rather than pop-up a new notification.
+ this.emit('done-displaying');
+ this.notification.destroy();
+ }
+ });
+
+ return button;
+ }
+
+ addAction(label, callback) {
+ const button = new St.Button({
+ style_class: 'notification-button',
+ label,
+ x_expand: true,
+ can_focus: true,
+ });
+
+ return this.addButton(button, callback);
+ }
+});
+
+var SourceActor = GObject.registerClass(
+class SourceActor extends St.Widget {
+ _init(source, size) {
+ super._init();
+
+ this._source = source;
+ this._size = size;
+
+ this.connect('destroy',
+ () => (this._actorDestroyed = true));
+ this._actorDestroyed = false;
+
+ let scaleFactor = St.ThemeContext.get_for_stage(global.stage).scale_factor;
+ this._iconBin = new St.Bin({
+ x_expand: true,
+ height: size * scaleFactor,
+ width: size * scaleFactor,
+ });
+
+ this.add_actor(this._iconBin);
+
+ this._source.connectObject('icon-updated',
+ this._updateIcon.bind(this), this);
+ this._updateIcon();
+ }
+
+ setIcon(icon) {
+ this._iconBin.child = icon;
+ this._iconSet = true;
+ }
+
+ _updateIcon() {
+ if (this._actorDestroyed)
+ return;
+
+ if (!this._iconSet)
+ this._iconBin.child = this._source.createIcon(this._size);
+ }
+});
+
+var Source = GObject.registerClass({
+ Properties: {
+ 'count': GObject.ParamSpec.int(
+ 'count', 'count', 'count',
+ GObject.ParamFlags.READABLE,
+ 0, GLib.MAXINT32, 0),
+ 'policy': GObject.ParamSpec.object(
+ 'policy', 'policy', 'policy',
+ GObject.ParamFlags.READWRITE,
+ NotificationPolicy.$gtype),
+ 'title': GObject.ParamSpec.string(
+ 'title', 'title', 'title',
+ GObject.ParamFlags.READWRITE,
+ null),
+ },
+ Signals: {
+ 'destroy': { param_types: [GObject.TYPE_UINT] },
+ 'icon-updated': {},
+ 'notification-added': { param_types: [Notification.$gtype] },
+ 'notification-show': { param_types: [Notification.$gtype] },
+ },
+}, class Source extends GObject.Object {
+ _init(title, iconName) {
+ super._init({ title });
+
+ this.SOURCE_ICON_SIZE = 48;
+
+ this.iconName = iconName;
+
+ this.isChat = false;
+
+ this.notifications = [];
+
+ this._policy = this._createPolicy();
+ }
+
+ get policy() {
+ return this._policy;
+ }
+
+ set policy(policy) {
+ if (this._policy)
+ this._policy.destroy();
+ this._policy = policy;
+ }
+
+ get count() {
+ return this.notifications.length;
+ }
+
+ get unseenCount() {
+ return this.notifications.filter(n => !n.acknowledged).length;
+ }
+
+ get countVisible() {
+ return this.count > 1;
+ }
+
+ countUpdated() {
+ this.notify('count');
+ }
+
+ _createPolicy() {
+ return new NotificationGenericPolicy();
+ }
+
+ get narrowestPrivacyScope() {
+ return this.notifications.every(n => n.privacyScope == PrivacyScope.SYSTEM)
+ ? PrivacyScope.SYSTEM
+ : PrivacyScope.USER;
+ }
+
+ setTitle(newTitle) {
+ if (this.title == newTitle)
+ return;
+
+ this.title = newTitle;
+ this.notify('title');
+ }
+
+ createBanner(notification) {
+ return new NotificationBanner(notification);
+ }
+
+ // Called to create a new icon actor.
+ // Provides a sane default implementation, override if you need
+ // something more fancy.
+ createIcon(size) {
+ return new St.Icon({
+ gicon: this.getIcon(),
+ icon_size: size,
+ });
+ }
+
+ getIcon() {
+ return new Gio.ThemedIcon({ name: this.iconName });
+ }
+
+ _onNotificationDestroy(notification) {
+ let index = this.notifications.indexOf(notification);
+ if (index < 0)
+ return;
+
+ this.notifications.splice(index, 1);
+ this.countUpdated();
+
+ if (this.notifications.length == 0)
+ this.destroy();
+ }
+
+ pushNotification(notification) {
+ if (this.notifications.includes(notification))
+ return;
+
+ while (this.notifications.length >= MAX_NOTIFICATIONS_PER_SOURCE)
+ this.notifications.shift().destroy(NotificationDestroyedReason.EXPIRED);
+
+ notification.connect('destroy', this._onNotificationDestroy.bind(this));
+ notification.connect('notify::acknowledged', this.countUpdated.bind(this));
+ this.notifications.push(notification);
+ this.emit('notification-added', notification);
+
+ this.countUpdated();
+ }
+
+ showNotification(notification) {
+ notification.acknowledged = false;
+ this.pushNotification(notification);
+
+ if (notification.urgency === Urgency.LOW)
+ return;
+
+ if (this.policy.showBanners || notification.urgency == Urgency.CRITICAL)
+ this.emit('notification-show', notification);
+ }
+
+ destroy(reason) {
+ let notifications = this.notifications;
+ this.notifications = [];
+
+ for (let i = 0; i < notifications.length; i++)
+ notifications[i].destroy(reason);
+
+ this.emit('destroy', reason);
+
+ this.policy.destroy();
+ this.run_dispose();
+ }
+
+ iconUpdated() {
+ this.emit('icon-updated');
+ }
+
+ // To be overridden by subclasses
+ open() {
+ }
+
+ destroyNonResidentNotifications() {
+ for (let i = this.notifications.length - 1; i >= 0; i--) {
+ if (!this.notifications[i].resident)
+ this.notifications[i].destroy();
+ }
+ }
+});
+SignalTracker.registerDestroyableType(Source);
+
+var MessageTray = GObject.registerClass({
+ Signals: {
+ 'queue-changed': {},
+ 'source-added': { param_types: [Source.$gtype] },
+ 'source-removed': { param_types: [Source.$gtype] },
+ },
+}, class MessageTray extends St.Widget {
+ _init() {
+ super._init({
+ visible: false,
+ clip_to_allocation: true,
+ layout_manager: new Clutter.BinLayout(),
+ });
+
+ this._presence = new GnomeSession.Presence((proxy, _error) => {
+ this._onStatusChanged(proxy.status);
+ });
+ this._busy = false;
+ this._bannerBlocked = false;
+ this._presence.connectSignal('StatusChanged', (proxy, senderName, [status]) => {
+ this._onStatusChanged(status);
+ });
+
+ let constraint = new Layout.MonitorConstraint({ primary: true });
+ Main.layoutManager.panelBox.bind_property('visible',
+ constraint, 'work-area',
+ GObject.BindingFlags.SYNC_CREATE);
+ this.add_constraint(constraint);
+
+ this._bannerBin = new St.Widget({
+ name: 'notification-container',
+ reactive: true,
+ track_hover: true,
+ y_align: Clutter.ActorAlign.START,
+ x_align: Clutter.ActorAlign.CENTER,
+ y_expand: true,
+ x_expand: true,
+ layout_manager: new Clutter.BinLayout(),
+ });
+ this._bannerBin.connect('key-release-event',
+ this._onNotificationKeyRelease.bind(this));
+ this._bannerBin.connect('notify::hover',
+ this._onNotificationHoverChanged.bind(this));
+ this.add_actor(this._bannerBin);
+
+ this._notificationFocusGrabber = new FocusGrabber(this._bannerBin);
+ this._notificationQueue = [];
+ this._notification = null;
+ this._banner = null;
+
+ this._userActiveWhileNotificationShown = false;
+
+ this.idleMonitor = global.backend.get_core_idle_monitor();
+
+ this._useLongerNotificationLeftTimeout = false;
+
+ // pointerInNotification is sort of a misnomer -- it tracks whether
+ // a message tray notification should expand. The value is
+ // partially driven by the hover state of the notification, but has
+ // a lot of complex state related to timeouts and the current
+ // state of the pointer when a notification pops up.
+ this._pointerInNotification = false;
+
+ // This tracks this._bannerBin.hover and is used to fizzle
+ // out non-changing hover notifications in onNotificationHoverChanged.
+ this._notificationHovered = false;
+
+ this._notificationState = State.HIDDEN;
+ this._notificationTimeoutId = 0;
+ this._notificationRemoved = false;
+
+ Main.layoutManager.addChrome(this, { affectsInputRegion: false });
+ Main.layoutManager.trackChrome(this._bannerBin, { affectsInputRegion: true });
+
+ global.display.connect('in-fullscreen-changed', this._updateState.bind(this));
+
+ Main.sessionMode.connect('updated', this._sessionUpdated.bind(this));
+
+ Main.overview.connect('window-drag-begin',
+ this._onDragBegin.bind(this));
+ Main.overview.connect('window-drag-cancelled',
+ this._onDragEnd.bind(this));
+ Main.overview.connect('window-drag-end',
+ this._onDragEnd.bind(this));
+
+ Main.overview.connect('item-drag-begin',
+ this._onDragBegin.bind(this));
+ Main.overview.connect('item-drag-cancelled',
+ this._onDragEnd.bind(this));
+ Main.overview.connect('item-drag-end',
+ this._onDragEnd.bind(this));
+
+ Main.xdndHandler.connect('drag-begin',
+ this._onDragBegin.bind(this));
+ Main.xdndHandler.connect('drag-end',
+ this._onDragEnd.bind(this));
+
+ Main.wm.addKeybinding('focus-active-notification',
+ new Gio.Settings({ schema_id: SHELL_KEYBINDINGS_SCHEMA }),
+ Meta.KeyBindingFlags.NONE,
+ Shell.ActionMode.NORMAL |
+ Shell.ActionMode.OVERVIEW,
+ this._expandActiveNotification.bind(this));
+
+ this._sources = new Set();
+
+ this._sessionUpdated();
+ }
+
+ _sessionUpdated() {
+ this._updateState();
+ }
+
+ _onDragBegin() {
+ Shell.util_set_hidden_from_pick(this, true);
+ }
+
+ _onDragEnd() {
+ Shell.util_set_hidden_from_pick(this, false);
+ }
+
+ get bannerAlignment() {
+ return this._bannerBin.get_x_align();
+ }
+
+ set bannerAlignment(align) {
+ this._bannerBin.set_x_align(align);
+ }
+
+ _onNotificationKeyRelease(actor, event) {
+ if (event.get_key_symbol() == Clutter.KEY_Escape && event.get_state() == 0) {
+ this._expireNotification();
+ return Clutter.EVENT_STOP;
+ }
+
+ return Clutter.EVENT_PROPAGATE;
+ }
+
+ _expireNotification() {
+ this._notificationExpired = true;
+ this._updateState();
+ }
+
+ get queueCount() {
+ return this._notificationQueue.length;
+ }
+
+ set bannerBlocked(v) {
+ if (this._bannerBlocked == v)
+ return;
+ this._bannerBlocked = v;
+ this._updateState();
+ }
+
+ contains(source) {
+ return this._sources.has(source);
+ }
+
+ add(source) {
+ if (this.contains(source)) {
+ log(`Trying to re-add source ${source.title}`);
+ return;
+ }
+
+ // Register that we got a notification for this source
+ source.policy.store();
+
+ source.policy.connect('notify::enable', () => {
+ this._onSourceEnableChanged(source.policy, source);
+ });
+ source.policy.connect('notify', this._updateState.bind(this));
+ this._onSourceEnableChanged(source.policy, source);
+ }
+
+ _addSource(source) {
+ this._sources.add(source);
+
+ source.connectObject(
+ 'notification-show', this._onNotificationShow.bind(this),
+ 'destroy', () => this._removeSource(source), this);
+
+ this.emit('source-added', source);
+ }
+
+ _removeSource(source) {
+ this._sources.delete(source);
+ source.disconnectObject(this);
+ this.emit('source-removed', source);
+ }
+
+ getSources() {
+ return [...this._sources.keys()];
+ }
+
+ _onSourceEnableChanged(policy, source) {
+ let wasEnabled = this.contains(source);
+ let shouldBeEnabled = policy.enable;
+
+ if (wasEnabled != shouldBeEnabled) {
+ if (shouldBeEnabled)
+ this._addSource(source);
+ else
+ this._removeSource(source);
+ }
+ }
+
+ _onNotificationDestroy(notification) {
+ if (this._notification === notification) {
+ this._notificationRemoved = true;
+ if (this._notificationState === State.SHOWN ||
+ this._notificationState === State.SHOWING) {
+ this._updateNotificationTimeout(0);
+ this._updateState();
+ }
+ } else {
+ const index = this._notificationQueue.indexOf(notification);
+ if (index !== -1) {
+ this._notificationQueue.splice(index, 1);
+ this.emit('queue-changed');
+ }
+ }
+ }
+
+ _onNotificationShow(_source, notification) {
+ if (this._notification == notification) {
+ // If a notification that is being shown is updated, we update
+ // how it is shown and extend the time until it auto-hides.
+ // If a new notification is updated while it is being hidden,
+ // we stop hiding it and show it again.
+ this._updateShowingNotification();
+ } else if (!this._notificationQueue.includes(notification)) {
+ // If the queue is "full", we skip banner mode and just show a small
+ // indicator in the panel; however do make an exception for CRITICAL
+ // notifications, as only banner mode allows expansion.
+ let bannerCount = this._notification ? 1 : 0;
+ let full = this.queueCount + bannerCount >= MAX_NOTIFICATIONS_IN_QUEUE;
+ if (!full || notification.urgency == Urgency.CRITICAL) {
+ notification.connect('destroy',
+ this._onNotificationDestroy.bind(this));
+ this._notificationQueue.push(notification);
+ this._notificationQueue.sort(
+ (n1, n2) => n2.urgency - n1.urgency);
+ this.emit('queue-changed');
+ }
+ }
+ this._updateState();
+ }
+
+ _resetNotificationLeftTimeout() {
+ this._useLongerNotificationLeftTimeout = false;
+ if (this._notificationLeftTimeoutId) {
+ GLib.source_remove(this._notificationLeftTimeoutId);
+ this._notificationLeftTimeoutId = 0;
+ this._notificationLeftMouseX = -1;
+ this._notificationLeftMouseY = -1;
+ }
+ }
+
+ _onNotificationHoverChanged() {
+ if (this._bannerBin.hover == this._notificationHovered)
+ return;
+
+ this._notificationHovered = this._bannerBin.hover;
+ if (this._notificationHovered) {
+ this._resetNotificationLeftTimeout();
+
+ if (this._showNotificationMouseX >= 0) {
+ let actorAtShowNotificationPosition =
+ global.stage.get_actor_at_pos(Clutter.PickMode.ALL, this._showNotificationMouseX, this._showNotificationMouseY);
+ this._showNotificationMouseX = -1;
+ this._showNotificationMouseY = -1;
+ // Don't set this._pointerInNotification to true if the pointer was initially in the area where the notification
+ // popped up. That way we will not be expanding notifications that happen to pop up over the pointer
+ // automatically. Instead, the user is able to expand the notification by mousing away from it and then
+ // mousing back in. Because this is an expected action, we set the boolean flag that indicates that a longer
+ // timeout should be used before popping down the notification.
+ if (this._bannerBin.contains(actorAtShowNotificationPosition)) {
+ this._useLongerNotificationLeftTimeout = true;
+ return;
+ }
+ }
+
+ this._pointerInNotification = true;
+ this._updateState();
+ } else {
+ // We record the position of the mouse the moment it leaves the tray. These coordinates are used in
+ // this._onNotificationLeftTimeout() to determine if the mouse has moved far enough during the initial timeout for us
+ // to consider that the user intended to leave the tray and therefore hide the tray. If the mouse is still
+ // close to its previous position, we extend the timeout once.
+ let [x, y] = global.get_pointer();
+ this._notificationLeftMouseX = x;
+ this._notificationLeftMouseY = y;
+
+ // We wait just a little before hiding the message tray in case the user quickly moves the mouse back into it.
+ // We wait for a longer period if the notification popped up where the mouse pointer was already positioned.
+ // That gives the user more time to mouse away from the notification and mouse back in in order to expand it.
+ let timeout = this._useLongerNotificationLeftTimeout ? LONGER_HIDE_TIMEOUT : HIDE_TIMEOUT;
+ this._notificationLeftTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout, this._onNotificationLeftTimeout.bind(this));
+ GLib.Source.set_name_by_id(this._notificationLeftTimeoutId, '[gnome-shell] this._onNotificationLeftTimeout');
+ }
+ }
+
+ _onStatusChanged(status) {
+ if (status == GnomeSession.PresenceStatus.BUSY) {
+ // remove notification and allow the summary to be closed now
+ this._updateNotificationTimeout(0);
+ this._busy = true;
+ } else if (status != GnomeSession.PresenceStatus.IDLE) {
+ // We preserve the previous value of this._busy if the status turns to IDLE
+ // so that we don't start showing notifications queued during the BUSY state
+ // as the screensaver gets activated.
+ this._busy = false;
+ }
+
+ this._updateState();
+ }
+
+ _onNotificationLeftTimeout() {
+ let [x, y] = global.get_pointer();
+ // We extend the timeout once if the mouse moved no further than MOUSE_LEFT_ACTOR_THRESHOLD to either side.
+ if (this._notificationLeftMouseX > -1 &&
+ y < this._notificationLeftMouseY + MOUSE_LEFT_ACTOR_THRESHOLD &&
+ y > this._notificationLeftMouseY - MOUSE_LEFT_ACTOR_THRESHOLD &&
+ x < this._notificationLeftMouseX + MOUSE_LEFT_ACTOR_THRESHOLD &&
+ x > this._notificationLeftMouseX - MOUSE_LEFT_ACTOR_THRESHOLD) {
+ this._notificationLeftMouseX = -1;
+ this._notificationLeftTimeoutId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ LONGER_HIDE_TIMEOUT,
+ this._onNotificationLeftTimeout.bind(this));
+ GLib.Source.set_name_by_id(this._notificationLeftTimeoutId, '[gnome-shell] this._onNotificationLeftTimeout');
+ } else {
+ this._notificationLeftTimeoutId = 0;
+ this._useLongerNotificationLeftTimeout = false;
+ this._pointerInNotification = false;
+ this._updateNotificationTimeout(0);
+ this._updateState();
+ }
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _escapeTray() {
+ this._pointerInNotification = false;
+ this._updateNotificationTimeout(0);
+ this._updateState();
+ }
+
+ // All of the logic for what happens when occurs here; the various
+ // event handlers merely update variables such as
+ // 'this._pointerInNotification', 'this._traySummoned', etc, and
+ // _updateState() figures out what (if anything) needs to be done
+ // at the present time.
+ _updateState() {
+ let hasMonitor = Main.layoutManager.primaryMonitor != null;
+ this.visible = !this._bannerBlocked && hasMonitor && this._banner != null;
+ if (this._bannerBlocked || !hasMonitor)
+ return;
+
+ // If our state changes caused _updateState to be called,
+ // just exit now to prevent reentrancy issues.
+ if (this._updatingState)
+ return;
+
+ this._updatingState = true;
+
+ // Filter out acknowledged notifications.
+ let changed = false;
+ this._notificationQueue = this._notificationQueue.filter(n => {
+ changed ||= n.acknowledged;
+ return !n.acknowledged;
+ });
+
+ if (changed)
+ this.emit('queue-changed');
+
+ let hasNotifications = Main.sessionMode.hasNotifications;
+
+ if (this._notificationState == State.HIDDEN) {
+ let nextNotification = this._notificationQueue[0] || null;
+ if (hasNotifications && nextNotification) {
+ let limited = this._busy || Main.layoutManager.primaryMonitor.inFullscreen;
+ let showNextNotification = !limited || nextNotification.forFeedback || nextNotification.urgency == Urgency.CRITICAL;
+ if (showNextNotification)
+ this._showNotification();
+ }
+ } else if (this._notificationState === State.SHOWING ||
+ this._notificationState === State.SHOWN) {
+ let expired = (this._userActiveWhileNotificationShown &&
+ this._notificationTimeoutId == 0 &&
+ this._notification.urgency != Urgency.CRITICAL &&
+ !this._banner.focused &&
+ !this._pointerInNotification) || this._notificationExpired;
+ let mustClose = this._notificationRemoved || !hasNotifications || expired;
+
+ if (mustClose) {
+ let animate = hasNotifications && !this._notificationRemoved;
+ this._hideNotification(animate);
+ } else if (this._notificationState === State.SHOWN &&
+ this._pointerInNotification) {
+ if (!this._banner.expanded)
+ this._expandBanner(false);
+ else
+ this._ensureBannerFocused();
+ }
+ }
+
+ this._updatingState = false;
+
+ // Clean transient variables that are used to communicate actions
+ // to updateState()
+ this._notificationExpired = false;
+ }
+
+ _onIdleMonitorBecameActive() {
+ this._userActiveWhileNotificationShown = true;
+ this._updateNotificationTimeout(2000);
+ this._updateState();
+ }
+
+ _showNotification() {
+ this._notification = this._notificationQueue.shift();
+ this.emit('queue-changed');
+
+ this._userActiveWhileNotificationShown = this.idleMonitor.get_idletime() <= IDLE_TIME;
+ if (!this._userActiveWhileNotificationShown) {
+ // If the user isn't active, set up a watch to let us know
+ // when the user becomes active.
+ this.idleMonitor.add_user_active_watch(this._onIdleMonitorBecameActive.bind(this));
+ }
+
+ this._banner = this._notification.createBanner();
+ this._banner.connectObject(
+ 'done-displaying', this._escapeTray.bind(this),
+ 'unfocused', () => this._updateState(), this);
+
+ this._bannerBin.add_actor(this._banner);
+
+ this._bannerBin.opacity = 0;
+ this._bannerBin.y = -this._banner.height;
+ this.show();
+
+ Meta.disable_unredirect_for_display(global.display);
+ this._updateShowingNotification();
+
+ let [x, y] = global.get_pointer();
+ // We save the position of the mouse at the time when we started showing the notification
+ // in order to determine if the notification popped up under it. We make that check if
+ // the user starts moving the mouse and _onNotificationHoverChanged() gets called. We don't
+ // expand the notification if it just happened to pop up under the mouse unless the user
+ // explicitly mouses away from it and then mouses back in.
+ this._showNotificationMouseX = x;
+ this._showNotificationMouseY = y;
+ // We save the coordinates of the mouse at the time when we started showing the notification
+ // and then we update it in _notificationTimeout(). We don't pop down the notification if
+ // the mouse is moving towards it or within it.
+ this._lastSeenMouseX = x;
+ this._lastSeenMouseY = y;
+
+ this._resetNotificationLeftTimeout();
+ }
+
+ _updateShowingNotification() {
+ this._notification.acknowledged = true;
+ this._notification.playSound();
+
+ // We auto-expand notifications with CRITICAL urgency, or for which the relevant setting
+ // is on in the control center.
+ if (this._notification.urgency == Urgency.CRITICAL ||
+ this._notification.source.policy.forceExpanded)
+ this._expandBanner(true);
+
+ // We tween all notifications to full opacity. This ensures that both new notifications and
+ // notifications that might have been in the process of hiding get full opacity.
+ //
+ // We tween any notification showing in the banner mode to the appropriate height
+ // (which is banner height or expanded height, depending on the notification state)
+ // This ensures that both new notifications and notifications in the banner mode that might
+ // have been in the process of hiding are shown with the correct height.
+ //
+ // We use this._showNotificationCompleted() onComplete callback to extend the time the updated
+ // notification is being shown.
+
+ this._notificationState = State.SHOWING;
+ this._bannerBin.remove_all_transitions();
+ this._bannerBin.ease({
+ opacity: 255,
+ duration: ANIMATION_TIME,
+ mode: Clutter.AnimationMode.LINEAR,
+ });
+ this._bannerBin.ease({
+ y: 0,
+ duration: ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_BACK,
+ onComplete: () => {
+ this._notificationState = State.SHOWN;
+ this._showNotificationCompleted();
+ this._updateState();
+ },
+ });
+ }
+
+ _showNotificationCompleted() {
+ if (this._notification.urgency != Urgency.CRITICAL)
+ this._updateNotificationTimeout(NOTIFICATION_TIMEOUT);
+ }
+
+ _updateNotificationTimeout(timeout) {
+ if (this._notificationTimeoutId) {
+ GLib.source_remove(this._notificationTimeoutId);
+ this._notificationTimeoutId = 0;
+ }
+ if (timeout > 0) {
+ this._notificationTimeoutId =
+ GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout,
+ this._notificationTimeout.bind(this));
+ GLib.Source.set_name_by_id(this._notificationTimeoutId, '[gnome-shell] this._notificationTimeout');
+ }
+ }
+
+ _notificationTimeout() {
+ let [x, y] = global.get_pointer();
+ if (y < this._lastSeenMouseY - 10 && !this._notificationHovered) {
+ // The mouse is moving towards the notification, so don't
+ // hide it yet. (We just create a new timeout (and destroy
+ // the old one) each time because the bookkeeping is
+ // simpler.)
+ this._updateNotificationTimeout(1000);
+ } else if (this._useLongerNotificationLeftTimeout && !this._notificationLeftTimeoutId &&
+ (x != this._lastSeenMouseX || y != this._lastSeenMouseY)) {
+ // Refresh the timeout if the notification originally
+ // popped up under the pointer, and the pointer is hovering
+ // inside it.
+ this._updateNotificationTimeout(1000);
+ } else {
+ this._notificationTimeoutId = 0;
+ this._updateState();
+ }
+
+ this._lastSeenMouseX = x;
+ this._lastSeenMouseY = y;
+ return GLib.SOURCE_REMOVE;
+ }
+
+ _hideNotification(animate) {
+ this._notificationFocusGrabber.ungrabFocus();
+
+ this._banner.disconnectObject(this);
+
+ this._resetNotificationLeftTimeout();
+ this._bannerBin.remove_all_transitions();
+
+ if (animate) {
+ this._notificationState = State.HIDING;
+ this._bannerBin.ease({
+ opacity: 0,
+ duration: ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_BACK,
+ });
+ this._bannerBin.ease({
+ y: -this._bannerBin.height,
+ duration: ANIMATION_TIME,
+ mode: Clutter.AnimationMode.EASE_OUT_BACK,
+ onComplete: () => {
+ this._notificationState = State.HIDDEN;
+ this._hideNotificationCompleted();
+ this._updateState();
+ },
+ });
+ } else {
+ this._bannerBin.y = -this._bannerBin.height;
+ this._bannerBin.opacity = 0;
+ this._notificationState = State.HIDDEN;
+ this._hideNotificationCompleted();
+ }
+ }
+
+ _hideNotificationCompleted() {
+ let notification = this._notification;
+ this._notification = null;
+ if (!this._notificationRemoved && notification.isTransient)
+ notification.destroy(NotificationDestroyedReason.EXPIRED);
+
+ this._pointerInNotification = false;
+ this._notificationRemoved = false;
+ Meta.enable_unredirect_for_display(global.display);
+
+ this._banner.destroy();
+ this._banner = null;
+ this.hide();
+ }
+
+ _expandActiveNotification() {
+ if (!this._banner)
+ return;
+
+ this._expandBanner(false);
+ }
+
+ _expandBanner(autoExpanding) {
+ // Don't animate changes in notifications that are auto-expanding.
+ this._banner.expand(!autoExpanding);
+
+ // Don't focus notifications that are auto-expanding.
+ if (!autoExpanding)
+ this._ensureBannerFocused();
+ }
+
+ _ensureBannerFocused() {
+ this._notificationFocusGrabber.grabFocus();
+ }
+});
+
+var SystemNotificationSource = GObject.registerClass(
+class SystemNotificationSource extends Source {
+ _init() {
+ super._init(_("System Information"), 'dialog-information-symbolic');
+ }
+
+ open() {
+ this.destroy();
+ }
+});