gnome-shell/js/ui/messageTray.js

1561 lines
54 KiB
JavaScript
Raw Permalink Normal View History

// -*- 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 theyve 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.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
true),
'enable-sound': GObject.ParamSpec.boolean(
'enable-sound', 'enable-sound', 'enable-sound',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
true),
'show-banners': GObject.ParamSpec.boolean(
'show-banners', 'show-banners', 'show-banners',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
true),
'force-expanded': GObject.ParamSpec.boolean(
'force-expanded', 'force-expanded', 'force-expanded',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
false),
'show-in-lock-screen': GObject.ParamSpec.boolean(
'show-in-lock-screen', 'show-in-lock-screen', 'show-in-lock-screen',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
false),
'details-in-lock-screen': GObject.ParamSpec.boolean(
'details-in-lock-screen', 'details-in-lock-screen', 'details-in-lock-screen',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
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 enableSound() {
return this.enable_sound;
}
get showBanners() {
return this.show_banners;
}
get forceExpanded() {
return this.force_expanded;
}
get showInLockScreen() {
return this.show_in_lock_screen;
}
get detailsInLockScreen() {
return this.details_in_lock_screen;
}
});
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, flags) {
super.vfunc_allocate(box, flags);
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, flags);
}
_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 = null;
}
get policy() {
if (!this._policy)
this._policy = this._createPolicy();
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) {
this.policy.destroy();
let notifications = this.notifications;
this.notifications = [];
for (let i = 0; i < notifications.length; i++)
notifications[i].destroy(reason);
this.emit('destroy', reason);
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();
}
});