diff --git a/data/gnome-shell-icons.gresource.xml b/data/gnome-shell-icons.gresource.xml
index f0bf289c9..79a7eba54 100644
--- a/data/gnome-shell-icons.gresource.xml
+++ b/data/gnome-shell-icons.gresource.xml
@@ -6,6 +6,7 @@
scalable/actions/carousel-arrow-previous-symbolic.svg
scalable/actions/cog-wheel-symbolic.svg
scalable/actions/dark-mode-symbolic.svg
+ scalable/actions/group-collapse-symbolic.svg
scalable/actions/notification-expand-symbolic.svg
scalable/actions/ornament-check-symbolic.svg
scalable/actions/ornament-dot-checked-symbolic.svg
diff --git a/data/icons/scalable/actions/group-collapse-symbolic.svg b/data/icons/scalable/actions/group-collapse-symbolic.svg
new file mode 100644
index 000000000..53194d2bc
--- /dev/null
+++ b/data/icons/scalable/actions/group-collapse-symbolic.svg
@@ -0,0 +1,37 @@
+
+
diff --git a/data/theme/gnome-shell-sass/widgets/_message-list.scss b/data/theme/gnome-shell-sass/widgets/_message-list.scss
index b1c169f2e..5fc78a68e 100644
--- a/data/theme/gnome-shell-sass/widgets/_message-list.scss
+++ b/data/theme/gnome-shell-sass/widgets/_message-list.scss
@@ -69,6 +69,17 @@
margin: 0 $base_margin;
}
}
+
+ // close button
+ .message-collapse-button {
+ @extend .icon-button;
+ color: $fg_color;
+ background-color: transparentize($fg_color, 0.8);
+ padding: 4px !important;
+ border: 4px transparent solid;
+ &:hover {background-color: transparentize($fg_color, 0.7);}
+ &:active {background-color: transparentize($fg_color, 0.8);}
+ }
}
// message bubbles
@@ -78,6 +89,19 @@
margin: 0;
border-radius: $modal_radius;
+ background-color: if($variant == 'light', $card_bg_color, lighten($card_bg_color, 5%));
+
+ &:second-in-stack {
+ background-color: if($variant == 'light', darken($card_bg_color, 4%), darken($card_bg_color, 1%));
+ box-shadow: 0 1px 1px 0 $card_shadow_color;
+ }
+
+ &:lower-in-stack {
+ background-color: if($variant == 'light', darken($card_bg_color, 7%), darken($card_bg_color, 4%));
+ box-shadow: none;
+ border-color: if($variant == 'light', darken($card_bg_color, 10%), transparent); // a not ideal workaround for light theme
+ }
+
// message header
.message-header {
spacing: $base_padding;
diff --git a/js/ui/calendar.js b/js/ui/calendar.js
index d68ac6133..23ab464ef 100644
--- a/js/ui/calendar.js
+++ b/js/ui/calendar.js
@@ -878,4 +878,28 @@ class CalendarMessageList extends St.Widget {
this._clearButton, 'reactive',
GObject.BindingFlags.SYNC_CREATE);
}
+
+ maybeCollapseMessageGroupForEvent(event) {
+ if (!this._messageView.expandedGroup)
+ return Clutter.EVENT_PROPAGATE;
+
+ if (event.type() === Clutter.EventType.KEY_PRESS &&
+ event.get_key_symbol() === Clutter.KEY_Escape) {
+ this._messageView.collapse();
+ return Clutter.EVENT_STOP;
+ }
+
+ const targetActor = global.stage.get_event_actor(event);
+ const onScrollbar =
+ this._scrollView.contains(targetActor) &&
+ !this._messageView.contains(targetActor);
+
+ if ((event.type() === Clutter.EventType.BUTTON_PRESS ||
+ event.type() === Clutter.EventType.TOUCH_BEGIN) &&
+ !this._messageView.expandedGroup.contains(targetActor) &&
+ !onScrollbar)
+ this._messageView.collapse();
+
+ return Clutter.EVENT_PROPAGATE;
+ }
});
diff --git a/js/ui/dateMenu.js b/js/ui/dateMenu.js
index 92fff5ecb..39687c321 100644
--- a/js/ui/dateMenu.js
+++ b/js/ui/dateMenu.js
@@ -920,6 +920,18 @@ class DateMenuButton extends PanelMenu.Button {
this._messageList = new Calendar.CalendarMessageList();
hbox.add_child(this._messageList);
+ // Collapse notification groups when the user clicks outside of the expanded group
+ this.menu.actor.connectObject(
+ 'captured-event::button', (_, event) => {
+ return this._messageList.maybeCollapseMessageGroupForEvent(event);
+ },
+ 'captured-event::touch', (_, event) => {
+ return this._messageList.maybeCollapseMessageGroupForEvent(event);
+ },
+ 'captured-event::key', (_, event) => {
+ return this._messageList.maybeCollapseMessageGroupForEvent(event);
+ });
+
// Fill up the second column
const boxLayout = new CalendarColumnLayout([this._calendar, this._date]);
const vbox = new St.Widget({
diff --git a/js/ui/messageList.js b/js/ui/messageList.js
index 387c3b194..bf038f7d9 100644
--- a/js/ui/messageList.js
+++ b/js/ui/messageList.js
@@ -20,6 +20,13 @@ const MESSAGE_ANIMATION_TIME = 100;
const DEFAULT_EXPAND_LINES = 6;
+const GROUP_EXPENSION_TIME = 200;
+const MAX_VISIBLE_STACKED_MESSAGES = 3;
+const ADDITIONAL_BOTTOM_MARGIN_EXPANDED_GROUP = 15;
+const WIDTH_OFFSET_STACKED = 6;
+const HEIGHT_OFFSET_STACKED = 10;
+const HEIGHT_OFFSET_REDUCTION_STACKED = 1.4;
+
export const URLHighlighter = GObject.registerClass(
class URLHighlighter extends St.Label {
_init(text = '', lineWrap, allowMarkup) {
@@ -824,6 +831,10 @@ class MediaMessage extends Message {
const NotificationMessageGroup = GObject.registerClass({
Properties: {
+ 'expanded': GObject.ParamSpec.boolean(
+ 'expanded', null, null,
+ GObject.ParamFlags.READABLE,
+ false),
'has-urgent': GObject.ParamSpec.boolean(
'has-urgent', null, null,
GObject.ParamFlags.READWRITE,
@@ -835,26 +846,40 @@ const NotificationMessageGroup = GObject.registerClass({
},
Signals: {
'notification-added': {},
+ 'expand-toggle-requested': {},
},
}, class NotificationMessageGroup extends St.Widget {
constructor(source) {
+ const action = new Clutter.ClickAction();
+
+ // A widget that covers stacked messages so that they don't receive events
+ const cover = new St.Widget({
+ name: 'cover',
+ reactive: true,
+ });
+
const header = new St.BoxLayout({
style_class: 'message-group-header',
x_expand: true,
+ visible: false,
});
super({
style_class: 'message-notification-group',
x_expand: true,
- layout_manager: new Clutter.BoxLayout({
- orientation: Clutter.Orientation.VERTICAL,
- }),
+ layout_manager: new MessageGroupExpanderLayout(cover, header),
+ actions: action,
reactive: true,
});
+ // The cover is always the second child to prevent interaction
+ // with stacked messages when collapsed.
+ this._cover = cover;
+ // The headerBox will always be the last child
this._headerBox = header;
this.source = source;
+ this._expanded = false;
this._notificationToMessage = new Map();
this._nUrgent = 0;
this._focusChild = null;
@@ -871,7 +896,21 @@ const NotificationMessageGroup = GObject.registerClass({
this._headerBox.add_child(titleLabel);
+ this._unexpandButton = new St.Button({
+ style_class: 'message-collapse-button',
+ icon_name: 'group-collapse-symbolic',
+ x_align: Clutter.ActorAlign.END,
+ y_align: Clutter.ActorAlign.CENTER,
+ x_expand: true,
+ y_expand: true,
+ });
+
+ this._unexpandButton.connect('clicked', () => this.emit('expand-toggle-requested'));
+ action.connect('clicked', () => this.emit('expand-toggle-requested'));
+
+ this._headerBox.add_child(this._unexpandButton);
this.add_child(this._headerBox);
+ this.add_child(this._cover);
source.connectObject(
'notification-added', (_, notification) => this._addNotification(notification),
@@ -883,6 +922,11 @@ const NotificationMessageGroup = GObject.registerClass({
});
}
+ get expanded() {
+ // Consider this group to be expanded when it has only one message
+ return this._expanded || this._notificationToMessage.size === 1;
+ }
+
get hasUrgent() {
return this._nUrgent > 0;
}
@@ -898,6 +942,95 @@ const NotificationMessageGroup = GObject.registerClass({
return this._focusChild;
}
+ async expand() {
+ if (this._expanded)
+ return;
+
+ this._headerBox.show();
+ this._expanded = true;
+ this._updateStackedMessagesFade();
+ this.notify('expanded');
+ this._cover.hide();
+
+ await new Promise((resolve, _) => {
+ this.ease_property('@layout.expansion', 1, {
+ progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: GROUP_EXPENSION_TIME,
+ onComplete: () => resolve(),
+ });
+ });
+ }
+
+ async collapse() {
+ if (!this._expanded)
+ return;
+
+ this._notificationToMessage.forEach(message => message.unexpand(true));
+
+ // Give focus to the fully visible message
+ if (this.focusChild?.has_key_focus())
+ this.get_first_child().child.grab_key_focus();
+
+ this._expanded = false;
+ this.notify('expanded');
+ this._cover.show();
+ this._updateStackedMessagesFade();
+
+ await new Promise((resolve, _) => {
+ this.ease_property('@layout.expansion', 0, {
+ progress_mode: Clutter.AnimationMode.EASE_OUT_QUAD,
+ duration: GROUP_EXPENSION_TIME,
+ onComplete: () => resolve(),
+ });
+ });
+
+ this._headerBox.hide();
+ }
+
+ // Ensure that the cover is still below the top most message
+ _ensureCoverPosition() {
+ // If the group doesn't have any messages,
+ // don't move the cover before the headerBox
+ if (this.get_n_children() > 2)
+ this.set_child_at_index(this._cover, 1);
+ }
+
+ _updateStackedMessagesFade() {
+ const pseudoClasses = ['second-in-stack', 'lower-in-stack'];
+
+ // The group doesn't have any messages
+ if (this.get_n_children() < 3)
+ return;
+
+ const [top, cover, ...stack] = this.get_children();
+ const header = stack.pop();
+
+ console.assert(cover === this._cover,
+ 'Cover has expected stack position');
+ console.assert(header === this._headerBox,
+ 'Header has expected stack position');
+
+ // A message may have moved so we need to remove the classes from all messages
+ const messages = [top, ...stack];
+ messages.forEach(item => {
+ pseudoClasses.forEach(name => {
+ item.child.remove_style_pseudo_class(name);
+ });
+ });
+
+ if (!this.expanded && stack.length > 0) {
+ const stackTop = stack.shift();
+ stackTop.child.add_style_pseudo_class(pseudoClasses[0]);
+
+ // Use the same class for the third message and all messages
+ // after that, since they won't be visible anyways
+ stack.forEach(item => {
+ const message = item.child;
+ message.add_style_pseudo_class(pseudoClasses[1]);
+ });
+ }
+ }
+
canClose() {
return true;
}
@@ -929,6 +1062,7 @@ const NotificationMessageGroup = GObject.registerClass({
if (isUrgent)
this._nUrgent++;
+ const wasExpanded = this.expanded;
const item = new St.Bin({
child: message,
canFocus: false,
@@ -938,10 +1072,36 @@ const NotificationMessageGroup = GObject.registerClass({
scale_y: 0,
});
- message.connectObject('key-focus-in', this._onKeyFocusIn.bind(this), this);
+ message.connectObject(
+ 'key-focus-in', this._onKeyFocusIn.bind(this),
+ 'expanded', () => {
+ if (!this.expanded)
+ this.emit('expand-toggle-requested');
+ },
+ 'close', () => {
+ // If the group is collapsed and one notification is closed, close the entire group
+ if (!this.expanded) {
+ GObject.signal_stop_emission_by_name(message, 'close');
+ this.close();
+ }
+ },
+ 'clicked', () => {
+ if (!this.expanded) {
+ GObject.signal_stop_emission_by_name(message, 'clicked');
+ this.emit('expand-toggle-requested');
+ }
+ }, this);
let index = isUrgent ? 0 : this._nUrgent;
+ // If we add a child below the top child we need to adjust index to skip the cover child
+ if (index > 0)
+ index += 1;
+
this.insert_child_at_index(item, index);
+ this._ensureCoverPosition();
+ this._updateStackedMessagesFade();
+
+ item.layout_manager.scalingEnabled = this._expanded;
// The first message doesn't need to be animated since the entire group is animated
if (this._notificationToMessage.size > 1) {
@@ -955,6 +1115,9 @@ const NotificationMessageGroup = GObject.registerClass({
item.set_scale(1.0, 1.0);
}
+ if (wasExpanded !== this.expanded)
+ this.notify('expanded');
+
if (oldHasUrgent !== this.hasUrgent)
this.notify('has-urgent');
this.emit('notification-added');
@@ -969,6 +1132,8 @@ const NotificationMessageGroup = GObject.registerClass({
message.disconnectObject(this);
+ item.layout_manager.scalingEnabled = this._expanded;
+
item.ease({
scale_x: 0,
scale_y: 0,
@@ -977,10 +1142,29 @@ const NotificationMessageGroup = GObject.registerClass({
onComplete: () => {
item.destroy();
this._notificationToMessage.delete(notification);
+ this._ensureCoverPosition();
+ this._updateStackedMessagesFade();
+
+ if (this._notificationToMessage.size === 1)
+ this.emit('expand-toggle-requested');
},
});
}
+ vfunc_paint(paintContext) {
+ // Invert the paint order, so that messages are collapsed with the
+ // newest message (the first child) on top of the stack
+ for (const child of this.get_children().reverse())
+ child.paint(paintContext);
+ }
+
+ vfunc_pick(pickContext) {
+ // Invert the pick order, so that messages are collapsed with the
+ // newest message (the first child) on top of the stack
+ for (const child of this.get_children().reverse())
+ child.pick(pickContext);
+ }
+
vfunc_map() {
// Acknowledge all notifications once they are mapped
this._notificationToMessage.forEach((_, notification) => {
@@ -989,6 +1173,13 @@ const NotificationMessageGroup = GObject.registerClass({
super.vfunc_map();
}
+ vfunc_get_focus_chain() {
+ if (this.expanded)
+ return this.get_children();
+ else
+ return [this.get_first_child()];
+ }
+
_moveMessage(message, index) {
if (this.get_child_at_index(index) === message)
return;
@@ -1000,7 +1191,13 @@ const NotificationMessageGroup = GObject.registerClass({
duration: MESSAGE_ANIMATION_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => {
+ // If we add a child below the top child we need to adjust index to skip the cover child
+ if (index > 0)
+ index += 1;
+
this.set_child_at_index(item, index);
+ this._ensureCoverPosition();
+ this._updateStackedMessagesFade();
item.ease({
scale_x: 1,
scale_y: 1,
@@ -1010,6 +1207,235 @@ const NotificationMessageGroup = GObject.registerClass({
},
});
}
+
+ close() {
+ // If the group is closed, close all messages in this group
+ this._notificationToMessage.forEach(message => {
+ message.disconnectObject(this);
+ message.close();
+ });
+ }
+});
+
+const MessageGroupExpanderLayout = GObject.registerClass({
+ Properties: {
+ 'expansion': GObject.ParamSpec.double(
+ 'expansion', null, null,
+ GObject.ParamFlags.READWRITE,
+ 0, 1, 0),
+ },
+}, class MessageGroupExpanderLayout extends Clutter.LayoutManager {
+ constructor(cover, header) {
+ super();
+
+ this._cover = cover;
+ this._header = header;
+ this._expansion = 0;
+ }
+
+ get expansion() {
+ return this._expansion;
+ }
+
+ set expansion(v) {
+ v = Math.clamp(v, 0, 1);
+
+ if (v === this._expansion)
+ return;
+ this._expansion = v;
+ this.notify('expansion');
+
+ this.layout_changed();
+ }
+
+ getExpandedHeight(container, forWidth) {
+ let [minExpanded, natExpanded] = [0, 0];
+
+ container.get_children().forEach(child => {
+ // We don't need to measure the cover
+ if (child === this._cover)
+ return;
+
+ const [minChild, natChild] = child.get_preferred_height(forWidth);
+ minExpanded += minChild;
+ natExpanded += natChild;
+ });
+
+ // Add additional spacing after an expanded group
+ minExpanded += ADDITIONAL_BOTTOM_MARGIN_EXPANDED_GROUP;
+ natExpanded += ADDITIONAL_BOTTOM_MARGIN_EXPANDED_GROUP;
+
+ return [minExpanded, natExpanded];
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ return container.get_children().reduce((acc, child) => {
+ // We don't need to measure the cover
+ if (child === this._cover)
+ return [0, 0];
+
+ if (!child.visible)
+ return acc;
+
+ const [minChild, natChild] = child.get_preferred_width(forHeight);
+
+ return [
+ Math.max(minChild, acc[0]),
+ Math.max(natChild, acc[1]),
+ ];
+ }, [0, 0]);
+ }
+
+ vfunc_get_preferred_height(container, forWidth) {
+ let offset = HEIGHT_OFFSET_STACKED;
+ let [min, nat] = [0, 0];
+ let visibleCount = MAX_VISIBLE_STACKED_MESSAGES;
+
+ for (const child of container.get_children()) {
+ // We don't need to measure the cover and the header is behind the stacked messages
+ if (child === this._cover || child === this._header)
+ continue;
+
+ if (!child.visible)
+ continue;
+
+ // The first message is always fully shown
+ if (min === 0 || nat === 0) {
+ [min, nat] = child.get_preferred_height(forWidth);
+ } else {
+ min += offset;
+ nat += offset;
+ offset /= HEIGHT_OFFSET_REDUCTION_STACKED;
+ }
+
+ visibleCount--;
+
+ if (visibleCount === 0)
+ break;
+ }
+
+ const [minExpanded, natExpanded] = this.getExpandedHeight(container, forWidth);
+
+ [min, nat] = [
+ min + this._expansion * (minExpanded - min),
+ nat + this._expansion * (natExpanded - nat),
+ ];
+
+ return [min, nat];
+ }
+
+ vfunc_allocate(container, box) {
+ const childWidth = box.x2 - box.x1;
+ let fullY2 = box.y2;
+
+ if (this._cover.visible)
+ this._cover.allocate(box);
+
+ if (this._header.visible) {
+ const [min, nat_] = this._header.get_preferred_height(childWidth);
+ box.y2 = box.y1 + min;
+ this._header.allocate(box);
+ box.y1 += this._expansion * (box.y2 - box.y1);
+ }
+
+ // The group doesn't have any messages
+ if (container.get_n_children() < 3)
+ return;
+
+ let heightOffset = HEIGHT_OFFSET_STACKED;
+ const [top, cover, ...stack] = container.get_children();
+ const header = stack.pop();
+
+ console.assert(cover === this._cover,
+ 'Cover has expected stack position');
+ console.assert(header === this._header,
+ 'Header has expected stack position');
+
+ if (top) {
+ const [min, nat_] = top.get_preferred_height(childWidth);
+ // The first message is always fully shown
+ box.y2 = box.y1 + min;
+ top.allocate(box);
+ }
+
+ stack.forEach(child => {
+ const [min, nat_] = child.get_preferred_height(childWidth);
+
+ // Reduce width of children when collapsed
+ const widthOffset = (1.0 - this._expansion) * WIDTH_OFFSET_STACKED;
+ box.x1 += widthOffset;
+ box.x2 -= widthOffset;
+
+ // Stack children with a small reveal when collapsed
+ box.y2 += heightOffset + this._expansion * (min - heightOffset);
+ // Ensure messages are not placed outside the widget
+ if (box.y2 > fullY2)
+ box.y2 = fullY2;
+ else
+ heightOffset /= HEIGHT_OFFSET_REDUCTION_STACKED;
+ box.y1 = box.y2 - min;
+
+ child.allocate(box);
+ });
+ }
+});
+
+const MessageViewLayout = GObject.registerClass({
+}, class MessageViewLayout extends Clutter.LayoutManager {
+ constructor(overlay) {
+ super();
+ this._overlay = overlay;
+ }
+
+ vfunc_get_preferred_width(container, forHeight) {
+ const [min, nat] = container.get_children().reduce((acc, child) => {
+ const [minChild, natChild] = child.get_preferred_width(forHeight);
+
+ return [
+ Math.max(minChild, acc[0]),
+ Math.max(natChild, acc[1]),
+ ];
+ }, [0, 0]);
+
+ return [
+ min,
+ nat,
+ ];
+ }
+
+ vfunc_get_preferred_height(container, forWidth) {
+ let [min, nat] = [0, 0];
+
+ container.get_children().forEach(child => {
+ const [minChild, natChild] = child.get_preferred_height(forWidth);
+
+ min += minChild;
+ nat += natChild;
+ });
+
+ return [
+ min,
+ nat,
+ ];
+ }
+
+ vfunc_allocate(container, box) {
+ if (this._overlay?.visible)
+ this._overlay.allocate(box);
+
+ const width = box.x2 - box.x1;
+ // We need to use the order in messages since the children order is
+ // the render order and the expanded group needs to be the top most child
+ // and the overlay the child below it.
+ container.messages.forEach(message => {
+ const child = message.get_parent();
+
+ const [min, _] = child.get_preferred_height(width);
+ box.y2 = box.y1 + min;
+ child.allocate(box);
+ box.y1 = box.y2;
+ });
+ }
});
export const MessageView = GObject.registerClass({
@@ -1022,11 +1448,15 @@ export const MessageView = GObject.registerClass({
'empty', null, null,
GObject.ParamFlags.READABLE,
true),
+ 'expanded-group': GObject.ParamSpec.object(
+ 'expanded-group', null, null,
+ GObject.ParamFlags.READABLE,
+ Clutter.Actor),
},
Signals: {
'message-focused': {param_types: [Message]},
},
-}, class MessageView extends St.BoxLayout {
+}, class MessageView extends St.Viewport {
messages = [];
_notificationSourceToGroup = new Map();
@@ -1036,13 +1466,26 @@ export const MessageView = GObject.registerClass({
_mediaSource = new Mpris.MprisSource();
constructor() {
+ // Add an overlay that will be placed below the expanded group message
+ // to block interaction with other messages.
+ // Unfortunately there isn't a much better way to block
+ // interaction with widgets for this use-case.
+ const overlay = new Clutter.Actor({
+ reactive: true,
+ name: 'overlay',
+ visible: false,
+ });
+
super({
style_class: 'message-view',
- orientation: Clutter.Orientation.VERTICAL,
+ layout_manager: new MessageViewLayout(overlay),
x_expand: true,
y_expand: true,
});
+ this._overlay = overlay;
+ this.add_child(this._overlay);
+
this._setupMpris();
this._setupNotifications();
}
@@ -1059,6 +1502,13 @@ export const MessageView = GObject.registerClass({
this.emit('message-focused', messageActor);
}
+ vfunc_get_focus_chain() {
+ if (this.expandedGroup)
+ return [this.expandedGroup];
+ else
+ return this.messages.filter(m => m.visible).map(m => m.get_parent());
+ }
+
_addMessageAtIndex(message, index) {
if (this.messages.includes(message))
throw new Error('Message was already added previously');
@@ -1084,7 +1534,7 @@ export const MessageView = GObject.registerClass({
this.messages.splice(indexLocal, 1);
});
- this.insert_child_at_index(item, index);
+ this.add_child(item);
this.messages.splice(index, 0, message);
if (wasEmpty !== this.empty)
@@ -1116,7 +1566,6 @@ export const MessageView = GObject.registerClass({
duration: MESSAGE_ANIMATION_TIME,
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
onComplete: () => {
- this.set_child_at_index(item, index);
this.messages.splice(this.messages.indexOf(message), 1);
this.messages.splice(index, 0, message);
this.queue_relayout();
@@ -1235,6 +1684,12 @@ export const MessageView = GObject.registerClass({
group.connectObject(
'notify::focus-child', () => this._onKeyFocusIn(group.focusChild),
+ 'expand-toggle-requested', () => {
+ if (group.expanded)
+ this._setExpandedGroup(null).catch(logError);
+ else
+ this._setExpandedGroup(group).catch(logError);
+ },
'notify::has-urgent', () => {
if (group.hasUrgent)
this._nUrgent++;
@@ -1266,4 +1721,36 @@ export const MessageView = GObject.registerClass({
this._notificationSourceToGroup.delete(source);
}
+
+ get expandedGroup() {
+ return this._expandedGroup;
+ }
+
+ async _setExpandedGroup(group) {
+ const prevGroup = this._expandedGroup;
+
+ if (prevGroup === group)
+ return;
+
+ this._expandedGroup = group;
+ this.notify('expanded-group');
+
+ // Collapse the previously expanded group
+ await prevGroup?.collapse();
+
+ if (group) {
+ // Make sure that the overlay is the child below the expanded group
+ this.set_child_above_sibling(group.get_parent(), null);
+ this.set_child_below_sibling(this._overlay, group.get_parent());
+ this._overlay.show();
+ await group.expand();
+ } else {
+ this._overlay.hide();
+ }
+ }
+
+ // Collapse expanded notification group
+ collapse() {
+ this._setExpandedGroup(null).catch(logError);
+ }
});