messageList: Make notification groups expandable/collapsible

This implements the new expand/collapse feature for notification groups
according to the new notification designs [1].

[1] 9e2bed6f37/notifications-calendar/notifications-grouping.png

Part-of: <https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/3012>
This commit is contained in:
Julian Sparber 2025-02-03 16:53:45 +01:00 committed by Marge Bot
parent dae4179b3d
commit 47de83a922
6 changed files with 593 additions and 8 deletions

View File

@ -6,6 +6,7 @@
<file preprocess="xml-stripblanks">scalable/actions/carousel-arrow-previous-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/cog-wheel-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/dark-mode-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/group-collapse-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/notification-expand-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/ornament-check-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/ornament-dot-checked-symbolic.svg</file>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
height="16px"
viewBox="0 0 16 16"
width="16px"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="g456"
transform="matrix(1,0,0,-1,392,1185.9336)"
style="fill:#2e3436;fill-opacity:1">
<g
id="g143"
transform="translate(0,-0.50051875)"
style="fill:#2e3436;fill-opacity:1">
<g
id="g144"
transform="translate(0,0.5)"
style="fill:#2e3436;fill-opacity:1">
<path
d="m -388,1172.4341 c 0,-0.2656 0.1055,-0.5195 0.293,-0.707 0.3906,-0.3906 1.0234,-0.3906 1.414,0 l 2.293,2.293 2.293,-2.293 c 0.3906,-0.3906 1.0234,-0.3906 1.414,0 0.1875,0.1875 0.293,0.4414 0.293,0.707 0,0.2656 -0.1055,0.5195 -0.293,0.707 l -3,3 c -0.3906,0.3907 -1.0234,0.3907 -1.414,0 l -3,-3 c -0.1875,-0.1875 -0.293,-0.4414 -0.293,-0.707 z"
fill="#2e3436"
id="path177459-1"
style="fill:#2e3436;fill-opacity:1" />
<path
d="m -388,1183.4342 c 0,0.2656 0.1055,0.5195 0.293,0.707 0.3906,0.3906 1.0234,0.3906 1.414,0 l 2.293,-2.293 2.293,2.293 c 0.3906,0.3906 1.0234,0.3906 1.414,0 0.1875,-0.1875 0.293,-0.4414 0.293,-0.707 0,-0.2656 -0.1055,-0.5195 -0.293,-0.707 l -3,-3 c -0.3906,-0.3907 -1.0234,-0.3907 -1.414,0 l -3,3 c -0.1875,0.1875 -0.293,0.4414 -0.293,0.707 z"
fill="#2e3436"
id="path142"
style="fill:#2e3436;fill-opacity:1" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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;

View File

@ -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;
}
});

View File

@ -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({

View File

@ -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);
}
});