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.
This commit is contained in:
parent
4fddad510f
commit
110f0cd3df
3 changed files with 422 additions and 24 deletions
|
|
@ -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,7 +177,13 @@ 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(
|
||||||
|
Some(BAR_DEST),
|
||||||
|
BAR_PATH,
|
||||||
|
Some(BAR_IFACE),
|
||||||
|
"ToggleHistory",
|
||||||
|
&(),
|
||||||
|
)
|
||||||
.await?;
|
.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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: >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<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: >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<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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }}\
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue