Skip to main content

wayle_notification/core/
notification.rs

1use std::cmp::PartialEq;
2
3use chrono::{DateTime, Utc};
4use derive_more::Debug;
5use tokio::sync::broadcast;
6use tracing::instrument;
7use wayle_core::Property;
8use zbus::Connection;
9
10use super::{
11    controls::NotificationControls,
12    types::{Action, NotificationHints, NotificationProps},
13};
14use crate::{
15    error::Error,
16    events::NotificationEvent,
17    types::{Category, ClosedReason, Urgency},
18};
19
20/// A desktop notification.
21///
22/// Each notification displayed is allocated a unique ID by the server. This is unique
23/// within the session. While the notification server is running, the ID will not be
24/// recycled unless the capacity of a uint32 is exceeded.
25#[derive(Clone, Debug)]
26pub struct Notification {
27    #[debug(skip)]
28    zbus_connection: Connection,
29    #[debug(skip)]
30    notif_tx: broadcast::Sender<NotificationEvent>,
31
32    /// The ID of the notification
33    pub id: u32,
34    /// The optional name of the application sending the notification. This should be the
35    /// application's formal name, rather than some sort of ID. An example would be
36    /// "FredApp E-Mail Client," rather than "fredapp-email-client."
37    pub app_name: Property<Option<String>>,
38    /// An optional ID of an existing notification that this notification is intended to replace.
39    pub replaces_id: Property<Option<u32>>,
40    /// The notification icon.
41    pub app_icon: Property<Option<String>>,
42    /// This is a single line overview of the notification. For instance, "You have mail"
43    /// or "A friend has come online". It should generally not be longer than 40 characters,
44    /// though this is not a requirement, and server implementations should word wrap if
45    /// necessary. The summary must be encoded using UTF-8.
46    pub summary: Property<String>,
47    /// This is a multi-line body of text. Each line is a paragraph, server implementations
48    /// are free to word wrap them as they see fit.
49    ///
50    /// The body may contain simple markup as specified in Markup. It must be encoded using UTF-8.
51    ///
52    /// If the body is omitted, just the summary is displayed.
53    pub body: Property<Option<String>>,
54    /// Available actions for this notification.
55    ///
56    /// Each action has an identifier and a human-readable label.
57    /// The "default" action is typically invoked when clicking the notification body.
58    pub actions: Property<Vec<Action>>,
59    /// The default action, triggered when clicking the notification body.
60    pub default_action: Property<Option<Action>>,
61    /// Hints are a way to provide extra data to a notification server that the server may
62    /// be able to make use of.
63    ///
64    /// Neither clients nor notification servers are required to support any hints. Both
65    /// sides should assume that hints are not passed, and should ignore any hints they
66    /// do not understand.
67    pub hints: Property<Option<NotificationHints>>,
68    /// The timeout time in milliseconds since the display of the notification at which
69    /// the notification should automatically close.
70    ///
71    /// `None` = server decides, `Some(0)` = never expires, `Some(ms)` = timeout in milliseconds.
72    pub expire_timeout: Property<Option<u32>>,
73    /// The urgency level.
74    pub urgency: Property<Urgency>,
75    /// The type of notification this is.
76    pub category: Property<Option<Category>>,
77    /// When the notification was created.
78    pub timestamp: Property<DateTime<Utc>>,
79    /// Path to an image file from hints.
80    pub image_path: Property<Option<String>>,
81    /// Desktop entry name of the application.
82    pub desktop_entry: Property<Option<String>>,
83    /// Whether the notification should be transient (not persisted).
84    pub is_transient: Property<bool>,
85    /// Whether the notification stays after action invocation.
86    pub is_resident: Property<bool>,
87    /// Path to a sound file to play when the notification pops up.
88    pub sound_file: Property<Option<String>>,
89    /// A themeable named sound to play when the notification pops up.
90    pub sound_name: Property<Option<String>>,
91    /// Whether to suppress playing sounds for this notification.
92    pub suppress_sound: Property<bool>,
93    /// X position hint for notification placement.
94    pub x: Property<Option<i32>>,
95    /// Y position hint for notification placement.
96    pub y: Property<Option<i32>>,
97    /// Whether action IDs should be interpreted as icon names.
98    pub action_icons: Property<bool>,
99}
100
101impl PartialEq for Notification {
102    fn eq(&self, other: &Self) -> bool {
103        self.id == other.id
104    }
105}
106
107impl Notification {
108    pub(crate) fn new(
109        props: NotificationProps,
110        connection: Connection,
111        notif_tx: broadcast::Sender<NotificationEvent>,
112    ) -> Self {
113        Self::from_props(props, connection, notif_tx)
114    }
115
116    /// Dismisses the notification, removing it from history and emitting
117    /// the D-Bus NotificationClosed signal.
118    #[instrument(skip(self), fields(notification_id = %self.id))]
119    pub fn dismiss(&self) {
120        let _ = self.notif_tx.send(NotificationEvent::Remove(
121            self.id,
122            ClosedReason::DismissedByUser,
123        ));
124    }
125
126    /// Invoke an action on the notification.
127    ///
128    /// The notification is dismissed if it is not a resident.
129    ///
130    /// # Errors
131    /// Returns error if the D-Bus signal emission fails.
132    #[instrument(skip(self), fields(notification_id = %self.id, action = %action_key), err)]
133    pub async fn invoke(&self, action_key: &str) -> Result<(), Error> {
134        NotificationControls::invoke(&self.zbus_connection, &self.id, action_key).await?;
135        if !self.is_resident.get() {
136            let _ = self
137                .notif_tx
138                .send(NotificationEvent::Remove(self.id, ClosedReason::Closed));
139        }
140        Ok(())
141    }
142
143    #[allow(clippy::too_many_lines)]
144    fn from_props(
145        props: NotificationProps,
146        connection: Connection,
147        notif_tx: broadcast::Sender<NotificationEvent>,
148    ) -> Notification {
149        let app_name = if !props.app_name.is_empty() {
150            Some(props.app_name)
151        } else {
152            None
153        };
154
155        let app_icon = if !props.app_icon.is_empty() {
156            Some(props.app_icon)
157        } else {
158            None
159        };
160
161        let replaces_id = if props.replaces_id > 0 {
162            Some(props.replaces_id)
163        } else {
164            None
165        };
166
167        let body = if !props.body.is_empty() {
168            Some(props.body)
169        } else {
170            None
171        };
172
173        let urgency = &props
174            .hints
175            .get("urgency")
176            .and_then(|hint| hint.downcast_ref::<u8>().ok())
177            .map_or(Urgency::Normal, Urgency::from);
178
179        let category = props
180            .hints
181            .get("category")
182            .and_then(|hint| hint.downcast_ref::<String>().ok())
183            .and_then(|category| category.parse().ok());
184
185        let image_path = props
186            .hints
187            .get("image-path")
188            .and_then(|hint| hint.downcast_ref::<String>().ok());
189
190        let desktop_entry = props
191            .hints
192            .get("desktop-entry")
193            .and_then(|hint| hint.downcast_ref::<String>().ok());
194
195        let is_transient = props
196            .hints
197            .get("transient")
198            .and_then(|hint| hint.downcast_ref::<bool>().ok())
199            .unwrap_or(false);
200
201        let is_resident = props
202            .hints
203            .get("resident")
204            .and_then(|hint| hint.downcast_ref::<bool>().ok())
205            .unwrap_or(false);
206
207        let sound_file = props
208            .hints
209            .get("sound-file")
210            .and_then(|hint| hint.downcast_ref::<String>().ok());
211
212        let sound_name = props
213            .hints
214            .get("sound-name")
215            .and_then(|hint| hint.downcast_ref::<String>().ok());
216
217        let suppress_sound = props
218            .hints
219            .get("suppress-sound")
220            .and_then(|hint| hint.downcast_ref::<bool>().ok())
221            .unwrap_or(false);
222
223        let x = props
224            .hints
225            .get("x")
226            .and_then(|hint| hint.downcast_ref::<i32>().ok());
227
228        let y = props
229            .hints
230            .get("y")
231            .and_then(|hint| hint.downcast_ref::<i32>().ok());
232
233        let action_icons = props
234            .hints
235            .get("action-icons")
236            .and_then(|hint| hint.downcast_ref::<bool>().ok())
237            .unwrap_or(false);
238
239        let parsed_actions = Action::parse_dbus_actions(&props.actions);
240        let default_action = parsed_actions
241            .iter()
242            .find(|action| action.id == Action::DEFAULT_ID)
243            .cloned();
244
245        let hints = if !props.hints.is_empty() {
246            Some(props.hints)
247        } else {
248            None
249        };
250
251        let expire_timeout = match props.expire_timeout {
252            t if t > 0 => Some(t as u32),
253            0 => Some(0),
254            _ => None,
255        };
256
257        let id = props.id;
258
259        Self {
260            zbus_connection: connection.clone(),
261            notif_tx,
262            id,
263            app_name: Property::new(app_name),
264            app_icon: Property::new(app_icon),
265            replaces_id: Property::new(replaces_id),
266            summary: Property::new(props.summary),
267            actions: Property::new(parsed_actions),
268            default_action: Property::new(default_action),
269            body: Property::new(body),
270            hints: Property::new(hints),
271            expire_timeout: Property::new(expire_timeout),
272            urgency: Property::new(*urgency),
273            category: Property::new(category),
274            timestamp: Property::new(props.timestamp),
275            image_path: Property::new(image_path),
276            desktop_entry: Property::new(desktop_entry),
277            is_transient: Property::new(is_transient),
278            is_resident: Property::new(is_resident),
279            sound_file: Property::new(sound_file),
280            sound_name: Property::new(sound_name),
281            suppress_sound: Property::new(suppress_sound),
282            x: Property::new(x),
283            y: Property::new(y),
284            action_icons: Property::new(action_icons),
285        }
286    }
287}