From ba4a2665fa31c71e1e7be3eef5cbd323a3426d7c Mon Sep 17 00:00:00 2001 From: Erik Reider <35975961+ErikReider@users.noreply.github.com> Date: Mon, 29 May 2023 15:08:51 +0200 Subject: [PATCH] Add inline replies to notifications (#221) --- README.md | 1 + src/config.json.in | 1 + src/configModel/configModel.vala | 6 + src/configSchema.json | 5 + src/controlCenter/controlCenter.vala | 15 +- src/main.vala | 6 + src/notiDaemon/notiDaemon.vala | 4 + src/notiModel/notiModel.vala | 66 ++++--- src/notification/notification.ui | 49 ++++- src/notification/notification.vala | 171 +++++++++++++++++- .../notificationWindow.vala | 26 +++ src/style.css | 45 ++++- 12 files changed, 355 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 75ca0600..5fd9e200 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Post your setup here: [Config flex 💪](https://github.com/ErikReider/SwayNotif - Keyboard shortcuts - Notification body markup with image support +- Inline replies - A panel to view previous notifications - Show album art for notifications like Spotify - Do not disturb diff --git a/src/config.json.in b/src/config.json.in index cfc3128d..99fb9cbc 100644 --- a/src/config.json.in +++ b/src/config.json.in @@ -11,6 +11,7 @@ "control-center-margin-right": 0, "control-center-margin-left": 0, "notification-2fa-action": true, + "notification-inline-replies": false, "notification-icon-size": 64, "notification-body-image-height": 100, "notification-body-image-width": 200, diff --git a/src/configModel/configModel.vala b/src/configModel/configModel.vala index d4836b8b..01a0a67c 100644 --- a/src/configModel/configModel.vala +++ b/src/configModel/configModel.vala @@ -555,6 +555,12 @@ namespace SwayNotificationCenter { */ public bool notification_2fa_action { get; set; default = true; } + /** + * If notifications should display a text field to reply if the + * sender requests it. + */ + public bool notification_inline_replies { get; set; default = false; } + /** * Notification icon size, in pixels. */ diff --git a/src/configSchema.json b/src/configSchema.json index b04ac0e4..448a9ccb 100644 --- a/src/configSchema.json +++ b/src/configSchema.json @@ -80,6 +80,11 @@ "description": "If each notification should display a 'COPY \"1234\"' action", "default": true }, + "notification-inline-replies": { + "type": "boolean", + "description": "If notifications should display a text field to reply if the sender requests it. NOTE: Replying in popup notifications is only available if the compositor supports GTK Layer-Shell ON_DEMAND keyboard interactivity.", + "default": false + }, "notification-icon-size": { "type": "integer", "description": "The notification icon size (in pixels)", diff --git a/src/controlCenter/controlCenter.vala b/src/controlCenter/controlCenter.vala index 0c6ebcda..fa1c6fbe 100644 --- a/src/controlCenter/controlCenter.vala +++ b/src/controlCenter/controlCenter.vala @@ -133,6 +133,14 @@ namespace SwayNotificationCenter { // sometimes being passed through to unfucused application // Ex: Firefox in a fullscreen YouTube video this.key_release_event.connect ((w, event_key) => { + if (this.get_focus () is Gtk.Entry) { + switch (Gdk.keyval_name (event_key.keyval)) { + case "Escape": + this.set_focus (null); + return true; + } + return false; + } if (event_key.type == Gdk.EventType.KEY_RELEASE) { switch (Gdk.keyval_name (event_key.keyval)) { case "Escape": @@ -145,6 +153,7 @@ namespace SwayNotificationCenter { }); this.key_press_event.connect ((w, event_key) => { + if (this.get_focus () is Gtk.Entry) return false; if (event_key.type == Gdk.EventType.KEY_PRESS) { var children = list_box.get_children (); Notification noti = (Notification) @@ -205,7 +214,7 @@ namespace SwayNotificationCenter { } navigate_list (list_position); } - return true; + return false; }); // Switches the stack page depending on the @@ -464,7 +473,9 @@ namespace SwayNotificationCenter { public void add_notification (NotifyParams param, NotiDaemon noti_daemon) { - var noti = new Notification.regular (param, noti_daemon); + var noti = new Notification.regular (param, + noti_daemon, + NotificationType.CONTROL_CENTER); noti.grab_focus.connect ((w) => { uint i = list_box.get_children ().index (w); if (list_position != uint.MAX && list_position != i) { diff --git a/src/main.vala b/src/main.vala index 614c0de9..057b988c 100644 --- a/src/main.vala +++ b/src/main.vala @@ -3,6 +3,8 @@ namespace SwayNotificationCenter { static string ? style_path; static string ? config_path; + static uint layer_shell_protocol_version = 3; + static Settings self_settings; public void main (string[] args) { @@ -40,6 +42,10 @@ namespace SwayNotificationCenter { ConfigModel.init (config_path); Functions.load_css (style_path); + if (ConfigModel.instance.layer_shell) { + layer_shell_protocol_version = GtkLayerShell.get_protocol_version (); + } + swaync_daemon = new SwayncDaemon (); Bus.own_name (BusType.SESSION, "org.erikreider.swaync.cc", BusNameOwnerFlags.NONE, diff --git a/src/notiDaemon/notiDaemon.vala b/src/notiDaemon/notiDaemon.vala index 5c7e5e08..e8347712 100644 --- a/src/notiDaemon/notiDaemon.vala +++ b/src/notiDaemon/notiDaemon.vala @@ -113,6 +113,7 @@ namespace SwayNotificationCenter { "synchronous", "private-synchronous", "x-canonical-private-synchronous", + "inline-reply", }; } @@ -339,5 +340,8 @@ namespace SwayNotificationCenter { * support the concept of being able to "invoke" a notification. */ public signal void ActionInvoked (uint32 id, string action_key); + + /** To be used by the non-standard "inline-reply" capability */ + public signal void NotificationReplied (uint32 id, string text); } } diff --git a/src/notiModel/notiModel.vala b/src/notiModel/notiModel.vala index fdb388fc..5088c71b 100644 --- a/src/notiModel/notiModel.vala +++ b/src/notiModel/notiModel.vala @@ -102,6 +102,10 @@ namespace SwayNotificationCenter { private int priv_value { private get; private set; } public bool has_synch { public get; private set; } + /** Inline-replies */ + public Action ? inline_reply { get; set; } + public string ? inline_reply_placeholder { get; set; } + // Custom hints /** Disables scripting for notification */ public bool swaync_no_script { get; set; } @@ -133,30 +137,12 @@ namespace SwayNotificationCenter { this.replaces = false; this.has_synch = false; - s_hints (); - - Array ac_array = new Array (); - if (actions.length > 1 && actions.length % 2 == 0) { - for (int i = 0; i < actions.length; i++) { - var action = new Action (); - action.identifier = actions[i]; - action.name = actions[i + 1]; - if (action.name != null && action.identifier != null - && action.name != "" && action.identifier != "") { + parse_hints (); - if (action.identifier.down () == "default") { - default_action = action; - } else { - ac_array.append_val (action); - } - } - i++; - } - } - this.actions = ac_array; + parse_actions (actions); } - private void s_hints () { + private void parse_hints () { foreach (var hint in hints.get_keys ()) { Variant hint_value = hints[hint]; switch (hint) { @@ -239,12 +225,46 @@ namespace SwayNotificationCenter { urgency = UrgencyLevels.from_value (hint_value.get_byte ()); } break; + case "x-kde-reply-placeholder-text": + if (hint_value.is_of_type (VariantType.STRING)) { + inline_reply_placeholder = hint_value.get_string (); + } + break; + } + } + } + + private void parse_actions (string[] actions) { + Array parsed_actions = new Array (); + if (actions.length > 1 && actions.length % 2 == 0) { + for (int i = 0; i < actions.length; i++) { + var action = new Action (); + action.identifier = actions[i]; + action.name = actions[i + 1]; + if (action.name != null && action.identifier != null + && action.name != "" && action.identifier != "") { + + string id = action.identifier.down (); + switch (id) { + case "default": + default_action = action; + break; + case "inline-reply": + inline_reply = action; + break; + default: + parsed_actions.append_val (action); + break; + } + } + i++; } } + this.actions = parsed_actions; } public string to_string () { - var params = new HashTable (str_hash, str_equal); + var params = new HashTable (str_hash, str_equal); params.set ("applied_id", applied_id.to_string ()); params.set ("app_name", app_name); @@ -280,6 +300,8 @@ namespace SwayNotificationCenter { _actions += "\n\t" + _action.to_string (); } params.set ("actions", string.joinv ("", _actions)); + params.set ("inline-reply", inline_reply == null + ? null : inline_reply.to_string ()); string[] result = {}; foreach (var k in params.get_keys ()) { diff --git a/src/notification/notification.ui b/src/notification/notification.ui index a4db71f0..7c38125e 100644 --- a/src/notification/notification.ui +++ b/src/notification/notification.ui @@ -1,5 +1,5 @@ - + @@ -45,10 +45,10 @@ False vertical - + True False - False + GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK | GDK_STRUCTURE_MASK True @@ -192,6 +192,49 @@ 2 + + + False + + + True + True + GTK_INPUT_HINT_SPELLCHECK | GTK_INPUT_HINT_EMOJI | GTK_INPUT_HINT_NONE + + + + True + True + 0 + + + + + True + True + True + + + + False + True + 1 + + + + + + False + True + 3 + + diff --git a/src/notification/notification.vala b/src/notification/notification.vala index a867f80b..b5b2db89 100644 --- a/src/notification/notification.vala +++ b/src/notification/notification.vala @@ -1,4 +1,7 @@ namespace SwayNotificationCenter { + + public enum NotificationType { CONTROL_CENTER, POPUP } + [GtkTemplate (ui = "/org/erikreider/sway-notification-center/notification/notification.ui")] public class Notification : Gtk.ListBoxRow { [GtkChild] @@ -10,7 +13,11 @@ namespace SwayNotificationCenter { unowned Gtk.EventBox event_box; [GtkChild] - unowned Gtk.Button default_button; + unowned Gtk.EventBox default_action; + + + /** The default_action gesture. Allows clicks while not in swipe gesture. */ + private Gtk.GestureMultiPress gesture; [GtkChild] unowned Gtk.ProgressBar progress_bar; @@ -36,6 +43,17 @@ namespace SwayNotificationCenter { [GtkChild] unowned Gtk.Image body_image; + // Inline Reply + [GtkChild] + unowned Gtk.Box inline_reply_box; + [GtkChild] + unowned Gtk.Entry inline_reply_entry; + [GtkChild] + unowned Gtk.Button inline_reply_button; + + private bool default_action_down = false; + private bool default_action_in = false; + public static Gtk.IconSize icon_size = Gtk.IconSize.INVALID; private int notification_icon_size { get; default = ConfigModel.instance.notification_icon_size; } @@ -55,6 +73,12 @@ namespace SwayNotificationCenter { public NotifyParams param { get; construct; } public NotiDaemon noti_daemon { get; construct; } + public NotificationType notification_type { + get; + construct; + default = NotificationType.POPUP; + } + public uint timeout_delay { get; construct; } public uint timeout_low_delay { get; construct; } public uint timeout_critical_delay { get; construct; } @@ -63,6 +87,8 @@ namespace SwayNotificationCenter { public int number_of_body_lines { get; construct; default = 10; } + public bool has_inline_reply { get; private set; default = false; } + private int carousel_empty_widget_index = 0; private static Regex code_regex; @@ -83,18 +109,23 @@ namespace SwayNotificationCenter { /** Show a non-timed notification */ public Notification.regular (NotifyParams param, - NotiDaemon noti_daemon) { - Object (noti_daemon: noti_daemon, param: param); + NotiDaemon noti_daemon, + NotificationType notification_type) { + Object (noti_daemon: noti_daemon, + param: param, + notification_type: notification_type); } /** Show a timed notification */ public Notification.timed (NotifyParams param, NotiDaemon noti_daemon, + NotificationType notification_type, uint timeout, uint timeout_low, uint timeout_critical) { Object (noti_daemon: noti_daemon, param: param, + notification_type: notification_type, is_timed: true, timeout_delay: timeout, timeout_low_delay: timeout_low, @@ -116,6 +147,54 @@ namespace SwayNotificationCenter { stderr.printf ("Invalid regex: %s", e.message); } + // Build the default_action gesture. Makes clickes compatible with + // the Hdy Swipe gesture unlike a regular ::button_release_event + gesture = new Gtk.GestureMultiPress (default_action); + gesture.set_touch_only (false); + gesture.set_exclusive (true); + gesture.set_button (Gdk.BUTTON_PRIMARY); + gesture.set_propagation_phase (Gtk.PropagationPhase.BUBBLE); + gesture.pressed.connect ((_gesture, _n_press, _x, _y) => { + default_action_in = true; + default_action_down = true; + default_action_update_state (); + }); + gesture.released.connect ((gesture, _n_press, _x, _y) => { + // Emit released + if (!default_action_down) return; + default_action_down = false; + if (default_action_in) { + click_default_action (); + } + + Gdk.EventSequence ? sequence = gesture.get_current_sequence (); + if (sequence == null) { + default_action_in = false; + default_action_update_state (); + } + }); + gesture.update.connect ((gesture, sequence) => { + Gtk.GestureSingle gesture_single = (Gtk.GestureSingle) gesture; + if (sequence != gesture_single.get_current_sequence ()) return; + + Gtk.Allocation allocation; + double x, y; + + default_action.get_allocation (out allocation); + gesture.get_point (sequence, out x, out y); + bool in_button = (x >= 0 && y >= 0 && x < allocation.width && y < allocation.height); + if (default_action_in != in_button) { + default_action_in = in_button; + default_action_update_state (); + } + }); + gesture.cancel.connect ((_gesture, _sequence) => { + if (default_action_down) { + default_action_down = false; + default_action_update_state (); + } + }); + this.transition_time = ConfigModel.instance.transition_time; build_noti (); @@ -125,6 +204,18 @@ namespace SwayNotificationCenter { } } + private void default_action_update_state () { + bool pressed = default_action_in && default_action_down; + + Gtk.StateFlags flags = default_action.get_state_flags () & + ~(Gtk.StateFlags.PRELIGHT | Gtk.StateFlags.ACTIVE); + + if (default_action_in) flags |= Gtk.StateFlags.PRELIGHT; + if (pressed) flags |= Gtk.StateFlags.ACTIVE; + + default_action.set_state_flags (flags, true); + } + private void on_size_allocation (Gtk.Allocation _ignored) { // Force recomputing the allocated size of the wrapped GTK label in the body. // `queue_resize` alone DOES NOT WORK because it does not properly invalidate @@ -151,13 +242,31 @@ namespace SwayNotificationCenter { return true; }); - default_button.clicked.connect (click_default_action); + // Adds CSS :hover selector to EventBox + default_action.enter_notify_event.connect ((event) => { + if (event.detail != Gdk.NotifyType.INFERIOR + && event.window == default_action.get_window ()) { + default_action_in = true; + default_action_update_state (); + } + return true; + }); + default_action.leave_notify_event.connect ((event) => { + if (event.detail != Gdk.NotifyType.INFERIOR + && event.window == default_action.get_window ()) { + default_action_in = false; + default_action_update_state (); + } + return true; + }); + + default_action.unmap.connect (() => default_action_in = false); close_revealer.set_transition_duration (this.transition_time); close_button.clicked.connect (() => close_notification ()); - this.event_box.enter_notify_event.connect (() => { + this.event_box.enter_notify_event.connect ((event) => { close_revealer.set_reveal_child (true); remove_noti_timeout (); return false; @@ -207,6 +316,7 @@ namespace SwayNotificationCenter { set_body (); set_icon (); + set_inline_reply (); set_actions (); set_style_urgency (); @@ -311,7 +421,7 @@ namespace SwayNotificationCenter { } public void click_alt_action (uint index) { - List? children = alt_actions_box.get_children (); + List ? children = alt_actions_box.get_children (); uint length = children.length (); if (length == 0 || index >= length) return; @@ -356,6 +466,54 @@ namespace SwayNotificationCenter { } } + private void set_inline_reply () { + // Only show inline replies in popup notifications if the compositor + // supports ON_DEMAND layer shell keyboard interactivity + if (!ConfigModel.instance.notification_inline_replies + || (ConfigModel.instance.layer_shell + && layer_shell_protocol_version < 4 + && notification_type == NotificationType.POPUP)) { + return; + } + if (param.inline_reply == null) return; + + has_inline_reply = true; + + inline_reply_box.show (); + + inline_reply_entry.set_placeholder_text ( + param.inline_reply_placeholder ?? "Enter Text"); + // Set reply Button sensitivity to disabled if Entry text is empty + inline_reply_entry.bind_property ( + "text", + inline_reply_button, "sensitive", + BindingFlags.SYNC_CREATE, + (binding, srcval, ref targetval) => { + targetval.set_boolean (((string) srcval).strip ().length > 0); + return true; + }, + null); + + inline_reply_entry.key_release_event.connect ((w, event_key) => { + switch (Gdk.keyval_name (event_key.keyval)) { + case "Return": + inline_reply_button.clicked (); + return true; + default: + return false; + } + }); + + inline_reply_button.set_label (param.inline_reply.name ?? "Reply"); + inline_reply_button.clicked.connect (() => { + string text = inline_reply_entry.get_text ().strip (); + if (text.length == 0) return; + noti_daemon.NotificationReplied (param.applied_id, text); + // Dismiss notification without activating Action + action_clicked (null); + }); + } + private void set_actions () { // Check for security codes string ? code = parse_body_codes (); @@ -543,6 +701,7 @@ namespace SwayNotificationCenter { /** Forces the EventBox to reload its style_context #27 */ public void reload_style_context () { event_box.get_style_context ().changed (); + default_action.get_style_context ().changed (); } } } diff --git a/src/notificationWindow/notificationWindow.vala b/src/notificationWindow/notificationWindow.vala index 8c3b96c8..ad0a08c6 100644 --- a/src/notificationWindow/notificationWindow.vala +++ b/src/notificationWindow/notificationWindow.vala @@ -40,6 +40,8 @@ namespace SwayNotificationCenter { private double last_upper = 0; + Gee.HashSet inline_reply_notifications = new Gee.HashSet (); + private const int MAX_HEIGHT = 600; private NotificationWindow () { @@ -158,6 +160,7 @@ namespace SwayNotificationCenter { public delegate bool remove_iter_func (Notification notification); public void close_all_notifications (remove_iter_func ? func = null) { + inline_reply_notifications.clear (); if (!this.get_realized ()) return; foreach (var w in box.get_children ()) { Notification notification = (Notification) w; @@ -170,6 +173,17 @@ namespace SwayNotificationCenter { private void remove_notification (Notification ? noti, bool replaces) { // Remove notification and its destruction timeout if (noti != null) { +#if HAVE_LATEST_GTK_LAYER_SHELL + if (noti.has_inline_reply) { + inline_reply_notifications.remove (noti.param.applied_id); + if (inline_reply_notifications.size == 0 + && GtkLayerShell.get_keyboard_mode (this) + != GtkLayerShell.KeyboardMode.NONE) { + GtkLayerShell.set_keyboard_mode ( + this, GtkLayerShell.KeyboardMode.NONE); + } + } +#endif noti.remove_noti_timeout (); noti.destroy (); } @@ -188,9 +202,21 @@ namespace SwayNotificationCenter { NotiDaemon noti_daemon) { var noti = new Notification.timed (param, noti_daemon, + NotificationType.POPUP, ConfigModel.instance.timeout, ConfigModel.instance.timeout_low, ConfigModel.instance.timeout_critical); +#if HAVE_LATEST_GTK_LAYER_SHELL + if (noti.has_inline_reply) { + inline_reply_notifications.add (param.applied_id); + + if (GtkLayerShell.get_keyboard_mode (this) + != GtkLayerShell.KeyboardMode.ON_DEMAND) { + GtkLayerShell.set_keyboard_mode ( + this, GtkLayerShell.KeyboardMode.ON_DEMAND); + } + } +#endif if (list_reverse) { box.pack_start (noti); diff --git a/src/style.css b/src/style.css index 55925916..c9e93b37 100644 --- a/src/style.css +++ b/src/style.css @@ -6,11 +6,15 @@ @define-color noti-border-color rgba(255, 255, 255, 0.15); @define-color noti-bg rgb(48, 48, 48); +@define-color noti-bg-darker rgb(38, 38, 38); @define-color noti-bg-hover rgb(56, 56, 56); @define-color noti-bg-focus rgba(68, 68, 68, 0.6); @define-color noti-close-bg rgba(255, 255, 255, 0.1); @define-color noti-close-bg-hover rgba(255, 255, 255, 0.15); +@define-color text-color rgb(255, 255, 255); +@define-color text-color-disabled rgb(150, 150, 150); + @define-color bg-selected rgb(0, 128, 255); .notification-row { @@ -58,7 +62,7 @@ .close-button { background: @noti-close-bg; - color: white; + color: @text-color; text-shadow: none; padding: 0; border-radius: 100%; @@ -84,7 +88,8 @@ box-shadow: none; background: @noti-bg; border: 1px solid @noti-border-color; - color: white; + color: @text-color; + transition: all 0.15s ease-in-out; } .notification-default-action:hover, @@ -119,6 +124,32 @@ border-right: 1px solid @noti-border-color; } +.inline-reply { + margin-top: 8px; +} +.inline-reply-entry { + background: @noti-bg-darker; + color: @text-color; + caret-color: @text-color; + border: 1px solid @noti-border-color; + border-radius: 12px; +} +.inline-reply-button { + margin-left: 4px; + background: @noti-bg; + border: 1px solid @noti-border-color; + border-radius: 12px; + color: @text-color; +} +.inline-reply-button:disabled { + background: initial; + color: @text-color-disabled; + border: 1px solid transparent; +} +.inline-reply-button:hover { + background: @noti-bg-hover; +} + .image { } @@ -132,7 +163,7 @@ font-size: 16px; font-weight: bold; background: transparent; - color: white; + color: @text-color; text-shadow: none; } @@ -140,7 +171,7 @@ font-size: 16px; font-weight: bold; background: transparent; - color: white; + color: @text-color; text-shadow: none; margin-right: 18px; } @@ -149,7 +180,7 @@ font-size: 15px; font-weight: normal; background: transparent; - color: white; + color: @text-color; text-shadow: none; } @@ -183,7 +214,7 @@ } .widget-title > button { font-size: initial; - color: white; + color: @text-color; text-shadow: none; background: @noti-bg; border: 1px solid @noti-border-color; @@ -318,7 +349,7 @@ } .widget-inhibitors > button { font-size: initial; - color: white; + color: @text-color; text-shadow: none; background: @noti-bg; border: 1px solid @noti-border-color;