// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- /* exported NotificationPolicy, NotificationGenericPolicy, NotificationApplicationPolicy, Source, SourceActor, SourceActorWithLabel, 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 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._focusActorChangedId = 0; this._focused = false; } grabFocus() { if (this._focused) return; this._prevKeyFocusActor = global.stage.get_key_focus(); this._focusActorChangedId = global.stage.connect('notify::key-focus', this._focusActorChanged.bind(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; if (this._focusActorChangedId > 0) { global.stage.disconnect(this._focusActorChangedId); this._focusActorChangedId = 0; } 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({ 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/%s/'.format(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', '%s.desktop'.format(this.id)); 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._acknowledged = 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 }); } get acknowledged() { return this._acknowledged; } set acknowledged(v) { if (this._acknowledged == v) return; this._acknowledged = v; this.notify('acknowledged'); } setUrgency(urgency) { this.urgency = urgency; } setResident(resident) { this.resident = resident; if (this.resident) { if (this._activatedId) { this.disconnect(this._activatedId); this._activatedId = 0; } } else if (!this._activatedId) { this._activatedId = this.connect_after('activated', () => this.destroy()); } } 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'); } destroy(reason = NotificationDestroyedReason.DISMISSED) { if (this._activatedId) { this.disconnect(this._activatedId); delete this._activatedId; } this.emit('destroy', reason); this.run_dispose(); } }); 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._activatedId = this.notification.connect('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'); }); } _disconnectNotificationSignals() { super._disconnectNotificationSignals(); if (this._activatedId) this.notification.disconnect(this._activatedId); this._activatedId = 0; } _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) { let 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) { let 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._source.disconnect(this._iconUpdatedId); 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._iconUpdatedId = this._source.connect('icon-updated', this._updateIcon.bind(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 SourceActorWithLabel = GObject.registerClass( class SourceActorWithLabel extends SourceActor { _init(source, size) { super._init(source, size); this._counterLabel = new St.Label({ x_align: Clutter.ActorAlign.CENTER, x_expand: true, y_align: Clutter.ActorAlign.CENTER, y_expand: true }); this._counterBin = new St.Bin({ style_class: 'summary-source-counter', child: this._counterLabel, layout_manager: new Clutter.BinLayout() }); this._counterBin.hide(); this._counterBin.connect('style-changed', () => { let themeNode = this._counterBin.get_theme_node(); this._counterBin.translation_x = themeNode.get_length('-shell-counter-overlap-x'); this._counterBin.translation_y = themeNode.get_length('-shell-counter-overlap-y'); }); this.add_actor(this._counterBin); this._countUpdatedId = this._source.connect('notify::count', this._updateCount.bind(this)); this._updateCount(); this.connect('destroy', () => { this._source.disconnect(this._countUpdatedId); }); } vfunc_allocate(box) { super.vfunc_allocate(box); let childBox = new Clutter.ActorBox(); let [, , naturalWidth, naturalHeight] = this._counterBin.get_preferred_size(); let direction = this.get_text_direction(); if (direction == Clutter.TextDirection.LTR) { // allocate on the right in LTR childBox.x1 = box.x2 - naturalWidth; childBox.x2 = box.x2; } else { // allocate on the left in RTL childBox.x1 = 0; childBox.x2 = naturalWidth; } childBox.y1 = box.y2 - naturalHeight; childBox.y2 = box.y2; this._counterBin.allocate(childBox); } _updateCount() { if (this._actorDestroyed) return; this._counterBin.visible = this._source.countVisible; let text; if (this._source.count < 100) text = this._source.count.toString(); else text = String.fromCharCode(0x22EF); // midline horizontal ellipsis this._counterLabel.set_text(text); } }); 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() { super.notify('count'); } _createPolicy() { return new NotificationPolicy(); } 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 (this.policy.showBanners || notification.urgency == Urgency.CRITICAL) this.emit('notification-show', notification); else notification.playSound(); } notify(propName) { if (propName instanceof Notification) { try { throw new Error('Source.notify() has been moved to Source.showNotification()' + 'this code will break in the future'); } catch (e) { logError(e); this.showNotification(propName); return; } } super.notify(propName); } 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(); } } }); 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); }); global.stage.connect('enter-event', (a, ev) => { // HACK: St uses ClutterInputDevice for hover tracking, which // misses relevant X11 events when untracked actors are // involved (read: the notification banner in normal mode), // so fix up Clutter's view of the pointer position in // that case. let related = ev.get_related(); if (!related || this.contains(related)) global.sync_pointer(); }); 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._bannerClickedId = 0; this._userActiveWhileNotificationShown = false; this.idleMonitor = Meta.IdleMonitor.get_core(); 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 Map(); 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 %s'.format(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) { let obj = { showId: 0, destroyId: 0, }; this._sources.set(source, obj); obj.showId = source.connect('notification-show', this._onNotificationShow.bind(this)); obj.destroyId = source.connect('destroy', this._onSourceDestroy.bind(this)); this.emit('source-added', source); } _removeSource(source) { let obj = this._sources.get(source); this._sources.delete(source); source.disconnect(obj.showId); source.disconnect(obj.destroyId); 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); } } _onSourceDestroy(source) { this._removeSource(source); } _onNotificationDestroy(notification) { if (this._notification == notification && (this._notificationState == State.SHOWN || this._notificationState == State.SHOWING)) { this._updateNotificationTimeout(0); this._notificationRemoved = true; this._updateState(); return; } let 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 = 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.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._pointerInNotification && !this._banner.expanded) { this._expandBanner(false); } else if (this._pointerInNotification) { 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._bannerClickedId = this._banner.connect('done-displaying', this._escapeTray.bind(this)); this._bannerUnfocusedId = this._banner.connect('unfocused', () => { this._updateState(); }); 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(); if (this._bannerClickedId) { this._banner.disconnect(this._bannerClickedId); this._bannerClickedId = 0; } if (this._bannerUnfocusedId) { this._banner.disconnect(this._bannerUnfocusedId); this._bannerUnfocusedId = 0; } 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(); } });