From 110f0cd3df6878f7b105fafe4983ff24b7262c83 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:26:06 +0800 Subject: [PATCH] Add notification actions and inline reply Parse FDO action pairs onto popup buttons, emit ActionInvoked on click (default action on the body), and show an inline-reply field when senders request it. Closing still emits NotificationClosed. History persist is unchanged. --- src/notifications/mod.rs | 211 +++++++++++++++++++++++++++++++-- src/notifications/popup.rs | 231 ++++++++++++++++++++++++++++++++++--- src/theme.rs | 4 + 3 files changed, 422 insertions(+), 24 deletions(-) diff --git a/src/notifications/mod.rs b/src/notifications/mod.rs index eccc2e7..4668798 100644 --- a/src/notifications/mod.rs +++ b/src/notifications/mod.rs @@ -17,6 +17,28 @@ use zbus::zvariant::OwnedValue; /// warning) — it just piles up a new card next to it forever. const SYNCHRONOUS_HINT: &str = "x-canonical-private-synchronous"; +/// Spec + GNOME/KDE reserved action id for an inline reply field. Hidden +/// from the button row; submitting the field emits `NotificationReplied` +/// (and `ActionInvoked` with this key). See `popup::emit_replied`. +pub const INLINE_REPLY_KEY: &str = "inline-reply"; + +/// KDE placeholder hint. Presence (or an `inline-reply` action) is enough +/// to show the reply field — Discord/Telegram use the action, Plasma often +/// only the hint. +const KDE_REPLY_PLACEHOLDER: &str = "x-kde-reply-placeholder"; + +/// Advertised `GetCapabilities` strings. `body` is the original set; +/// `actions` / `inline-reply` are this change; `body-markup` is the usual +/// companion so senders can ship ``/`` instead of stripping tags. +const CAPABILITIES: &[&str] = &["body", "body-markup", "actions", "inline-reply"]; + +/// One `(id, localized label)` pair from the Notify `actions` array. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Action { + pub key: String, + pub label: String, +} + /// How long a shown notification should stay up before auto-dismissing. /// Distinct from `Option` mainly for readability at call sites — /// `Never` covers both the spec's `expire_timeout == 0` ("never expire") @@ -36,6 +58,9 @@ pub enum NotifEvent { body: String, urgency: Urgency, expire: Expire, + actions: Vec, + /// Placeholder for the inline-reply field, if one should be shown. + inline_reply: Option, }, Close(u32), ToggleHistory, @@ -68,6 +93,36 @@ impl Urgency { } } +/// Spec: `actions` is a flat list of pairs `(id, localized label)`. An +/// unpaired trailing id is ignored. Empty keys are dropped. +fn parse_actions(raw: &[String]) -> Vec { + raw.chunks_exact(2) + .filter(|c| !c[0].is_empty()) + .map(|c| Action { + key: c[0].clone(), + label: c[1].clone(), + }) + .collect() +} + +/// Show an inline reply field when the sender asked for `inline-reply` or +/// sent the KDE placeholder hint. Placeholder text prefers the hint. +fn inline_reply_placeholder( + actions: &[Action], + hints: &HashMap, +) -> Option { + let from_hint = hints + .get(KDE_REPLY_PLACEHOLDER) + .and_then(|v| String::try_from(v.clone()).ok()) + .filter(|s| !s.is_empty()); + let has_action = actions.iter().any(|a| a.key == INLINE_REPLY_KEY); + if has_action || from_hint.is_some() { + Some(from_hint.unwrap_or_else(|| "Reply".into())) + } else { + None + } +} + /// Maps a `Notify` call's `expire_timeout` (plus whether the `urgency` hint /// was critical) to our internal `Expire`, per the freedesktop notification /// spec: `0` always means never expire; a negative value means "server @@ -122,8 +177,14 @@ const BAR_IFACE: &str = "dev.breadway.Bar"; /// `breadbar --history`; does not start a second bar. pub async fn toggle_history_remote() -> zbus::Result<()> { let conn = zbus::Connection::session().await?; - conn.call_method(Some(BAR_DEST), BAR_PATH, Some(BAR_IFACE), "ToggleHistory", &()) - .await?; + conn.call_method( + Some(BAR_DEST), + BAR_PATH, + Some(BAR_IFACE), + "ToggleHistory", + &(), + ) + .await?; Ok(()) } @@ -138,7 +199,7 @@ impl NotifServer { _app_icon: &str, summary: &str, body: &str, - _actions: Vec, + actions: Vec, hints: std::collections::HashMap, expire_timeout: i32, ) -> u32 { @@ -171,6 +232,8 @@ impl NotifServer { // when the sender left expire_timeout at the server-default (-1). let urgency = Urgency::from_hint(hints.get("urgency")); let expire = compute_expire(expire_timeout, urgency == Urgency::Critical); + let actions = parse_actions(&actions); + let inline_reply = inline_reply_placeholder(&actions, &hints); history::record( &self.history, @@ -196,6 +259,8 @@ impl NotifServer { body: body.to_string(), urgency, expire, + actions, + inline_reply, }) .await; id @@ -206,7 +271,7 @@ impl NotifServer { } fn get_capabilities(&self) -> Vec { - vec!["body".to_string()] + CAPABILITIES.iter().map(|s| (*s).to_string()).collect() } fn get_server_information(&self) -> (String, String, String, String) { @@ -241,6 +306,8 @@ impl SampleKind { body: "This is what a notification card looks like.".into(), urgency, expire: Expire::Never, + actions: vec![], + inline_reply: None, } } } @@ -304,8 +371,7 @@ pub fn spawn(sample: Option) -> gtk4::Window { let window_for_loop = window.clone(); relm4::spawn_local(async move { if let Ok(conn) = conn_rx.await { - popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)) - .await; + popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)).await; } }); } @@ -457,10 +523,28 @@ mod tests { async fn notify_records_history_newest_first() { let (server, _rx) = test_server(); server - .notify("app-a", 0, "", "first", "body-a", vec![], HashMap::new(), -1) + .notify( + "app-a", + 0, + "", + "first", + "body-a", + vec![], + HashMap::new(), + -1, + ) .await; server - .notify("app-b", 0, "", "second", "body-b", vec![], HashMap::new(), -1) + .notify( + "app-b", + 0, + "", + "second", + "body-b", + vec![], + HashMap::new(), + -1, + ) .await; let hist = server.history.lock().unwrap(); assert_eq!(hist.len(), 2); @@ -469,4 +553,115 @@ mod tests { assert_eq!(hist[0].body, "body-b"); assert_eq!(hist[1].summary, "first"); } + + #[test] + fn parse_actions_pairs_and_drops_trailing_id() { + let parsed = parse_actions(&[ + "default".into(), + "Open".into(), + "snooze".into(), + "Snooze".into(), + "orphan".into(), + ]); + assert_eq!( + parsed, + vec![ + Action { + key: "default".into(), + label: "Open".into(), + }, + Action { + key: "snooze".into(), + label: "Snooze".into(), + }, + ] + ); + } + + #[test] + fn parse_actions_skips_empty_keys() { + assert!(parse_actions(&["", "Nope"].map(String::from)).is_empty()); + } + + #[test] + fn inline_reply_from_action_or_kde_hint() { + let reply_action = vec![Action { + key: INLINE_REPLY_KEY.into(), + label: "Reply".into(), + }]; + assert_eq!( + inline_reply_placeholder(&reply_action, &HashMap::new()).as_deref(), + Some("Reply") + ); + assert!(inline_reply_placeholder(&[], &HashMap::new()).is_none()); + + let mut hints = HashMap::new(); + hints.insert( + KDE_REPLY_PLACEHOLDER.to_string(), + OwnedValue::try_from(zbus::zvariant::Value::from("Write a reply…")).unwrap(), + ); + assert_eq!( + inline_reply_placeholder(&[], &hints).as_deref(), + Some("Write a reply…") + ); + // Hint wins over the generic default when both are present. + assert_eq!( + inline_reply_placeholder(&reply_action, &hints).as_deref(), + Some("Write a reply…") + ); + } + + #[test] + fn get_capabilities_includes_actions_and_inline_reply() { + let (server, _rx) = test_server(); + let caps = server.get_capabilities(); + for wanted in ["body", "body-markup", "actions", "inline-reply"] { + assert!( + caps.iter().any(|c| c == wanted), + "missing capability {wanted}" + ); + } + } + + #[tokio::test] + async fn notify_forwards_actions_and_inline_reply() { + let (server, mut rx) = test_server(); + server + .notify( + "chat", + 0, + "", + "Alice", + "hello", + vec![ + "default".into(), + "Open".into(), + INLINE_REPLY_KEY.into(), + "Reply".into(), + ], + HashMap::new(), + -1, + ) + .await; + match rx.recv().await.expect("Show event") { + NotifEvent::Show { + actions, + inline_reply, + summary, + .. + } => { + assert_eq!(summary, "Alice"); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0].key, "default"); + assert_eq!(actions[1].key, INLINE_REPLY_KEY); + assert_eq!(inline_reply.as_deref(), Some("Reply")); + } + _ => panic!("expected Show, got a different event"), + } + // History persist path is unchanged: actions are UI-only, not stored. + let hist = server.history.lock().unwrap(); + assert_eq!(hist.len(), 1); + assert_eq!(hist[0].summary, "Alice"); + assert_eq!(hist[0].body, "hello"); + } } diff --git a/src/notifications/popup.rs b/src/notifications/popup.rs index 9ec29af..f221524 100644 --- a/src/notifications/popup.rs +++ b/src/notifications/popup.rs @@ -1,10 +1,10 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc}; use gtk4::prelude::*; -use gtk4_layer_shell::{Edge, Layer, LayerShell}; +use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use tokio::sync::mpsc::Receiver; -use super::{history, Expire, NotifEvent, Urgency}; +use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY}; type Cards = Rc>>; // Bumped every time an id gets a (re)placed card — an auto-dismiss timer @@ -18,7 +18,6 @@ type Generations = Rc>>; /// NotificationClosed reason codes per the freedesktop spec. mod close_reason { pub const EXPIRED: u32 = 1; - #[allow(dead_code)] // no in-app dismiss button exists yet (see make_card) pub const DISMISSED_BY_USER: u32 = 2; pub const CLOSE_NOTIFICATION_CALL: u32 = 3; } @@ -63,12 +62,26 @@ pub async fn run( body, urgency, expire, + actions, + inline_reply, } => { // Replace existing card with same id (replaces_id case) if let Some(old) = cards.borrow_mut().remove(&id) { cards_box.remove(&old); } - let card = make_card(&app_name, &summary, &body, urgency); + let card = make_card(CardSpec { + id, + app_name: &app_name, + summary: &summary, + body: &body, + urgency, + actions: &actions, + inline_reply: inline_reply.as_deref(), + conn: conn.clone(), + cards: cards.clone(), + cards_box: cards_box.clone(), + window: window.clone(), + }); cards_box.prepend(&card); cards.borrow_mut().insert(id, card.clone()); window.set_visible(true); @@ -96,8 +109,7 @@ pub async fn run( gtk4::glib::timeout_future(duration).await; let still_current = generations_clone.borrow().get(&id) == Some(&my_generation); - if still_current - && dismiss(&cards_box_clone, &win_clone, &cards_clone, id) + if still_current && dismiss(&cards_box_clone, &win_clone, &cards_clone, id) { emit_closed(&conn_clone, id, close_reason::EXPIRED).await; } @@ -164,39 +176,226 @@ fn create_window() -> gtk4::Window { window.set_margin(Edge::Top, 20); window.set_margin(Edge::Right, 20); window.set_default_width(320); + // OnDemand so an inline-reply GtkEntry can take keys without the popup + // stealing every keystroke the rest of the time. + window.set_keyboard_mode(KeyboardMode::OnDemand); window } -fn make_card(app_name: &str, summary: &str, body: &str, urgency: Urgency) -> gtk4::Box { +struct CardSpec<'a> { + id: u32, + app_name: &'a str, + summary: &'a str, + body: &'a str, + urgency: Urgency, + actions: &'a [Action], + inline_reply: Option<&'a str>, + conn: Option, + cards: Cards, + cards_box: gtk4::Box, + window: gtk4::Window, +} + +fn make_card(spec: CardSpec<'_>) -> gtk4::Box { let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4); card.add_css_class("notification-card"); - if let Some(class) = urgency.css_class() { + if let Some(class) = spec.urgency.css_class() { card.add_css_class(class); } + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + // Senders often set the title/summary to their own app name (e.g. a bare // "Spotify" notification) — showing app_name above an identical summary // is pure repetition, so skip the app label in that case. - if !app_name.is_empty() && !app_name.eq_ignore_ascii_case(summary) { - let lbl = gtk4::Label::new(Some(app_name)); + if !spec.app_name.is_empty() && !spec.app_name.eq_ignore_ascii_case(spec.summary) { + let lbl = gtk4::Label::new(Some(spec.app_name)); lbl.add_css_class("notification-app"); lbl.set_xalign(0.0); - card.append(&lbl); + content.append(&lbl); } - let summary_lbl = gtk4::Label::new(Some(summary)); + let summary_lbl = gtk4::Label::new(Some(spec.summary)); summary_lbl.add_css_class("notification-summary"); summary_lbl.set_xalign(0.0); summary_lbl.set_wrap(true); - card.append(&summary_lbl); + content.append(&summary_lbl); - if !body.is_empty() { - let body_lbl = gtk4::Label::new(Some(body)); + if !spec.body.is_empty() { + let body_lbl = gtk4::Label::new(None); body_lbl.add_css_class("notification-body"); body_lbl.set_xalign(0.0); body_lbl.set_wrap(true); - card.append(&body_lbl); + apply_body_text(&body_lbl, spec.body); + content.append(&body_lbl); + } + + if spec.actions.iter().any(|a| a.key == "default") { + content.add_css_class("notification-default"); + let gesture = gtk4::GestureClick::new(); + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + gesture.connect_released(move |_, _, _, _| { + invoke_action(invoke.clone(), "default"); + }); + content.add_controller(gesture); + } + + card.append(&content); + + let visible: Vec<&Action> = spec + .actions + .iter() + .filter(|a| a.key != "default" && a.key != INLINE_REPLY_KEY) + .collect(); + if !visible.is_empty() { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + row.add_css_class("notification-actions"); + row.set_halign(gtk4::Align::End); + for action in visible { + let btn = gtk4::Button::with_label(&action.label); + btn.add_css_class("notification-action"); + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + let key = action.key.clone(); + btn.connect_clicked(move |_| { + invoke_action(invoke.clone(), &key); + }); + row.append(&btn); + } + card.append(&row); + } + + if let Some(placeholder) = spec.inline_reply { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); + row.add_css_class("notification-reply"); + + let entry = gtk4::Entry::new(); + entry.add_css_class("notification-reply-entry"); + entry.set_placeholder_text(Some(placeholder)); + entry.set_hexpand(true); + + let send_label = spec + .actions + .iter() + .find(|a| a.key == INLINE_REPLY_KEY) + .map(|a| a.label.as_str()) + .filter(|l| !l.is_empty()) + .unwrap_or("Send"); + let send = gtk4::Button::with_label(send_label); + send.add_css_class("notification-action"); + + let invoke = Invoke { + conn: spec.conn.clone(), + cards: spec.cards.clone(), + cards_box: spec.cards_box.clone(), + window: spec.window.clone(), + id: spec.id, + }; + let entry_for_btn = entry.clone(); + let invoke_btn = invoke.clone(); + send.connect_clicked(move |_| { + submit_reply(&entry_for_btn, invoke_btn.clone()); + }); + entry.connect_activate(move |e| { + submit_reply(e, invoke.clone()); + }); + + row.append(&entry); + row.append(&send); + card.append(&row); } card } + +/// FDO `body-markup` is a small Pango-ish subset (``, ``, ``, +/// ``). Invalid markup falls back to plain text so a bad sender +/// doesn't blank the card. +fn apply_body_text(label: >k4::Label, body: &str) { + if body.contains('<') && gtk4::pango::parse_markup(body, '\0').is_ok() { + label.set_markup(body); + return; + } + label.set_text(body); +} + +#[derive(Clone)] +struct Invoke { + conn: Option, + cards: Cards, + cards_box: gtk4::Box, + window: gtk4::Window, + id: u32, +} + +fn invoke_action(invoke: Invoke, key: &str) { + let key = key.to_string(); + relm4::spawn_local(async move { + emit_action(&invoke.conn, invoke.id, &key).await; + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + +fn submit_reply(entry: >k4::Entry, invoke: Invoke) { + let text = entry.text().to_string(); + if text.trim().is_empty() { + return; + } + relm4::spawn_local(async move { + emit_replied(&invoke.conn, invoke.id, &text).await; + emit_action(&invoke.conn, invoke.id, INLINE_REPLY_KEY).await; + if dismiss(&invoke.cards_box, &invoke.window, &invoke.cards, invoke.id) { + emit_closed(&invoke.conn, invoke.id, close_reason::DISMISSED_BY_USER).await; + } + }); +} + +async fn emit_action(conn: &Option, id: u32, action_key: &str) { + let Some(conn) = conn else { return }; + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "ActionInvoked", + &(id, action_key), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit ActionInvoked for {id}: {e}"); + } +} + +/// GNOME/KDE (and clients such as Discord/Telegram) listen for this +/// non-spec signal on `org.freedesktop.Notifications` when the user +/// submits an inline reply. Signature: `NotificationReplied(u32 id, s text)`. +/// We also emit `ActionInvoked(id, "inline-reply")` so senders that only +/// watch the spec signal still see the send. +async fn emit_replied(conn: &Option, id: u32, text: &str) { + let Some(conn) = conn else { return }; + let result = conn + .emit_signal( + None::<&str>, + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + "NotificationReplied", + &(id, text), + ) + .await; + if let Err(e) = result { + eprintln!("breadbar: failed to emit NotificationReplied for {id}: {e}"); + } +} diff --git a/src/theme.rs b/src/theme.rs index 0e7186f..11820bf 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -44,6 +44,10 @@ fn load_css() -> String { .notification-card.urgency-normal {{ border-left-color: {accent}; }}\ .notification-summary {{ font-weight: bold; }}\ .notification-app {{ opacity: 0.6; }}\ + .notification-actions {{ margin-top: 6px; }}\ + .notification-action {{ padding: 2px 8px; font-size: 11px; }}\ + .notification-reply {{ margin-top: 6px; }}\ + .notification-reply-entry {{ min-width: 0; }}\ .history-title {{ font-weight: bold; font-size: 13px; }}\ .history-close {{ padding: 2px 8px; }}\ .history-empty {{ opacity: 0.5; padding: 8px 0; }}\