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.
|
||||
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.
|
||||
/// Distinct from `Option<Duration>` 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<Action>,
|
||||
/// Placeholder for the inline-reply field, if one should be shown.
|
||||
inline_reply: Option<String>,
|
||||
},
|
||||
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<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
|
||||
/// was critical) to our internal `Expire`, per the freedesktop notification
|
||||
/// 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.
|
||||
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", &())
|
||||
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<String>,
|
||||
actions: Vec<String>,
|
||||
hints: std::collections::HashMap<String, OwnedValue>,
|
||||
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<String> {
|
||||
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<SampleKind>) -> 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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RefCell<HashMap<u32, gtk4::Box>>>;
|
||||
// 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.
|
||||
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<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);
|
||||
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 (`<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-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; }}\
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue