Add notification actions and inline reply
Some checks failed
check / check (push) Failing after 10s
dev release / build (push) Successful in 1m0s

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.
This commit is contained in:
Breadway 2026-08-16 00:26:06 +08:00
parent 4fddad510f
commit 110f0cd3df
3 changed files with 422 additions and 24 deletions

View file

@ -17,6 +17,28 @@ use zbus::zvariant::OwnedValue;
/// warning) — it just piles up a new card next to it forever. /// warning) — it just piles up a new card next to it forever.
const SYNCHRONOUS_HINT: &str = "x-canonical-private-synchronous"; 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 `<b>`/`<i>` 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. /// How long a shown notification should stay up before auto-dismissing.
/// Distinct from `Option<Duration>` mainly for readability at call sites — /// Distinct from `Option<Duration>` mainly for readability at call sites —
/// `Never` covers both the spec's `expire_timeout == 0` ("never expire") /// `Never` covers both the spec's `expire_timeout == 0` ("never expire")
@ -36,6 +58,9 @@ pub enum NotifEvent {
body: String, body: String,
urgency: Urgency, urgency: Urgency,
expire: Expire, expire: Expire,
actions: Vec<Action>,
/// Placeholder for the inline-reply field, if one should be shown.
inline_reply: Option<String>,
}, },
Close(u32), Close(u32),
ToggleHistory, 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<Action> {
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<String, OwnedValue>,
) -> Option<String> {
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 /// Maps a `Notify` call's `expire_timeout` (plus whether the `urgency` hint
/// was critical) to our internal `Expire`, per the freedesktop notification /// was critical) to our internal `Expire`, per the freedesktop notification
/// spec: `0` always means never expire; a negative value means "server /// 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. /// `breadbar --history`; does not start a second bar.
pub async fn toggle_history_remote() -> zbus::Result<()> { pub async fn toggle_history_remote() -> zbus::Result<()> {
let conn = zbus::Connection::session().await?; let conn = zbus::Connection::session().await?;
conn.call_method(Some(BAR_DEST), BAR_PATH, Some(BAR_IFACE), "ToggleHistory", &()) conn.call_method(
.await?; Some(BAR_DEST),
BAR_PATH,
Some(BAR_IFACE),
"ToggleHistory",
&(),
)
.await?;
Ok(()) Ok(())
} }
@ -138,7 +199,7 @@ impl NotifServer {
_app_icon: &str, _app_icon: &str,
summary: &str, summary: &str,
body: &str, body: &str,
_actions: Vec<String>, actions: Vec<String>,
hints: std::collections::HashMap<String, OwnedValue>, hints: std::collections::HashMap<String, OwnedValue>,
expire_timeout: i32, expire_timeout: i32,
) -> u32 { ) -> u32 {
@ -171,6 +232,8 @@ impl NotifServer {
// when the sender left expire_timeout at the server-default (-1). // when the sender left expire_timeout at the server-default (-1).
let urgency = Urgency::from_hint(hints.get("urgency")); let urgency = Urgency::from_hint(hints.get("urgency"));
let expire = compute_expire(expire_timeout, urgency == Urgency::Critical); let expire = compute_expire(expire_timeout, urgency == Urgency::Critical);
let actions = parse_actions(&actions);
let inline_reply = inline_reply_placeholder(&actions, &hints);
history::record( history::record(
&self.history, &self.history,
@ -196,6 +259,8 @@ impl NotifServer {
body: body.to_string(), body: body.to_string(),
urgency, urgency,
expire, expire,
actions,
inline_reply,
}) })
.await; .await;
id id
@ -206,7 +271,7 @@ impl NotifServer {
} }
fn get_capabilities(&self) -> Vec<String> { fn get_capabilities(&self) -> Vec<String> {
vec!["body".to_string()] CAPABILITIES.iter().map(|s| (*s).to_string()).collect()
} }
fn get_server_information(&self) -> (String, String, String, String) { 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(), body: "This is what a notification card looks like.".into(),
urgency, urgency,
expire: Expire::Never, expire: Expire::Never,
actions: vec![],
inline_reply: None,
} }
} }
} }
@ -304,8 +371,7 @@ pub fn spawn(sample: Option<SampleKind>) -> gtk4::Window {
let window_for_loop = window.clone(); let window_for_loop = window.clone();
relm4::spawn_local(async move { relm4::spawn_local(async move {
if let Ok(conn) = conn_rx.await { if let Ok(conn) = conn_rx.await {
popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)) popup::run(window_for_loop, cards_box, rx, Some(conn), Some(history_ui)).await;
.await;
} }
}); });
} }
@ -457,10 +523,28 @@ mod tests {
async fn notify_records_history_newest_first() { async fn notify_records_history_newest_first() {
let (server, _rx) = test_server(); let (server, _rx) = test_server();
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; .await;
server server
.notify("app-b", 0, "", "second", "body-b", vec![], HashMap::new(), -1) .notify(
"app-b",
0,
"",
"second",
"body-b",
vec![],
HashMap::new(),
-1,
)
.await; .await;
let hist = server.history.lock().unwrap(); let hist = server.history.lock().unwrap();
assert_eq!(hist.len(), 2); assert_eq!(hist.len(), 2);
@ -469,4 +553,115 @@ mod tests {
assert_eq!(hist[0].body, "body-b"); assert_eq!(hist[0].body, "body-b");
assert_eq!(hist[1].summary, "first"); 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");
}
} }

View file

@ -1,10 +1,10 @@
use std::{cell::RefCell, collections::HashMap, rc::Rc}; use std::{cell::RefCell, collections::HashMap, rc::Rc};
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4_layer_shell::{Edge, Layer, LayerShell}; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use super::{history, Expire, NotifEvent, Urgency}; use super::{history, Action, Expire, NotifEvent, Urgency, INLINE_REPLY_KEY};
type Cards = Rc<RefCell<HashMap<u32, gtk4::Box>>>; type Cards = Rc<RefCell<HashMap<u32, gtk4::Box>>>;
// Bumped every time an id gets a (re)placed card — an auto-dismiss timer // Bumped every time an id gets a (re)placed card — an auto-dismiss timer
@ -18,7 +18,6 @@ type Generations = Rc<RefCell<HashMap<u32, u64>>>;
/// NotificationClosed reason codes per the freedesktop spec. /// NotificationClosed reason codes per the freedesktop spec.
mod close_reason { mod close_reason {
pub const EXPIRED: u32 = 1; 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 DISMISSED_BY_USER: u32 = 2;
pub const CLOSE_NOTIFICATION_CALL: u32 = 3; pub const CLOSE_NOTIFICATION_CALL: u32 = 3;
} }
@ -63,12 +62,26 @@ pub async fn run(
body, body,
urgency, urgency,
expire, expire,
actions,
inline_reply,
} => { } => {
// Replace existing card with same id (replaces_id case) // Replace existing card with same id (replaces_id case)
if let Some(old) = cards.borrow_mut().remove(&id) { if let Some(old) = cards.borrow_mut().remove(&id) {
cards_box.remove(&old); 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_box.prepend(&card);
cards.borrow_mut().insert(id, card.clone()); cards.borrow_mut().insert(id, card.clone());
window.set_visible(true); window.set_visible(true);
@ -96,8 +109,7 @@ pub async fn run(
gtk4::glib::timeout_future(duration).await; gtk4::glib::timeout_future(duration).await;
let still_current = let still_current =
generations_clone.borrow().get(&id) == Some(&my_generation); generations_clone.borrow().get(&id) == Some(&my_generation);
if still_current if still_current && dismiss(&cards_box_clone, &win_clone, &cards_clone, id)
&& dismiss(&cards_box_clone, &win_clone, &cards_clone, id)
{ {
emit_closed(&conn_clone, id, close_reason::EXPIRED).await; 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::Top, 20);
window.set_margin(Edge::Right, 20); window.set_margin(Edge::Right, 20);
window.set_default_width(320); 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 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<zbus::Connection>,
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); let card = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
card.add_css_class("notification-card"); 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); 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 // Senders often set the title/summary to their own app name (e.g. a bare
// "Spotify" notification) — showing app_name above an identical summary // "Spotify" notification) — showing app_name above an identical summary
// is pure repetition, so skip the app label in that case. // is pure repetition, so skip the app label in that case.
if !app_name.is_empty() && !app_name.eq_ignore_ascii_case(summary) { if !spec.app_name.is_empty() && !spec.app_name.eq_ignore_ascii_case(spec.summary) {
let lbl = gtk4::Label::new(Some(app_name)); let lbl = gtk4::Label::new(Some(spec.app_name));
lbl.add_css_class("notification-app"); lbl.add_css_class("notification-app");
lbl.set_xalign(0.0); 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.add_css_class("notification-summary");
summary_lbl.set_xalign(0.0); summary_lbl.set_xalign(0.0);
summary_lbl.set_wrap(true); summary_lbl.set_wrap(true);
card.append(&summary_lbl); content.append(&summary_lbl);
if !body.is_empty() { if !spec.body.is_empty() {
let body_lbl = gtk4::Label::new(Some(body)); let body_lbl = gtk4::Label::new(None);
body_lbl.add_css_class("notification-body"); body_lbl.add_css_class("notification-body");
body_lbl.set_xalign(0.0); body_lbl.set_xalign(0.0);
body_lbl.set_wrap(true); 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 card
} }
/// FDO `body-markup` is a small Pango-ish subset (`<b>`, `<i>`, `<u>`,
/// `<a href>`). Invalid markup falls back to plain text so a bad sender
/// doesn't blank the card.
fn apply_body_text(label: &gtk4::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<zbus::Connection>,
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: &gtk4::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<zbus::Connection>, 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<zbus::Connection>, 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}");
}
}

View file

@ -44,6 +44,10 @@ fn load_css() -> String {
.notification-card.urgency-normal {{ border-left-color: {accent}; }}\ .notification-card.urgency-normal {{ border-left-color: {accent}; }}\
.notification-summary {{ font-weight: bold; }}\ .notification-summary {{ font-weight: bold; }}\
.notification-app {{ opacity: 0.6; }}\ .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-title {{ font-weight: bold; font-size: 13px; }}\
.history-close {{ padding: 2px 8px; }}\ .history-close {{ padding: 2px 8px; }}\
.history-empty {{ opacity: 0.5; padding: 8px 0; }}\ .history-empty {{ opacity: 0.5; padding: 8px 0; }}\