Skip to main content

origin_platform/
notifications.rs

1use async_trait::async_trait;
2use origin_domain::Result;
3use serde::{Deserialize, Serialize};
4use std::fmt::Debug;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum Urgency {
9    Low,
10    #[default]
11    Normal,
12    Critical,
13}
14
15/// One action offered on a notification (B6).
16///
17/// The action *is* the label plus a stable id: the host renders a button and reports
18/// the id back, so a product reacts to `"mark-read"` rather than to a translated label.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct NotificationAction {
21    pub id: String,
22    pub label: String,
23}
24
25impl NotificationAction {
26    pub fn new(id: impl Into<String>, label: impl Into<String>) -> Self {
27        Self {
28            id: id.into(),
29            label: label.into(),
30        }
31    }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct Notification {
36    pub title: String,
37    pub body: Option<String>,
38    pub urgency: Urgency,
39    /// Groups related notifications so a repeat replaces the previous one instead of
40    /// stacking. Usually the alert fingerprint.
41    pub tag: Option<String>,
42    /// Buttons on the notification itself (B6).
43    ///
44    /// Recording them is platform-independent; whether a host can *show* them is not
45    /// — macOS, for instance, needs the notification category registered up front. A
46    /// host that cannot render actions shows the notification without them rather than
47    /// failing.
48    #[serde(default)]
49    pub actions: Vec<NotificationAction>,
50}
51
52impl Notification {
53    pub fn new(title: impl Into<String>) -> Self {
54        Self {
55            title: title.into(),
56            body: None,
57            urgency: Urgency::Normal,
58            tag: None,
59            actions: Vec::new(),
60        }
61    }
62
63    pub fn with_body(mut self, body: impl Into<String>) -> Self {
64        self.body = Some(body.into());
65        self
66    }
67
68    pub fn with_urgency(mut self, urgency: Urgency) -> Self {
69        self.urgency = urgency;
70        self
71    }
72
73    pub fn with_tag(mut self, tag: impl Into<String>) -> Self {
74        self.tag = Some(tag.into());
75        self
76    }
77
78    /// Attach an action button. The host reports `id` back when the user presses it.
79    pub fn with_action(mut self, action: NotificationAction) -> Self {
80        self.actions.push(action);
81        self
82    }
83}
84
85/// Native user notifications.
86///
87/// Implementations must not fail the caller when the user has denied notification
88/// permission — a suppressed notification is a normal outcome, not an error.
89#[async_trait]
90pub trait NotificationService: Debug + Send + Sync + 'static {
91    async fn notify(&self, notification: Notification) -> Result<()>;
92}
93
94/// Drops every notification. Used for headless runs and CLI builds.
95#[derive(Debug, Clone, Copy, Default)]
96pub struct NoopNotificationService;
97
98#[async_trait]
99impl NotificationService for NoopNotificationService {
100    async fn notify(&self, notification: Notification) -> Result<()> {
101        tracing::debug!(title = %notification.title, "notification dropped (noop service)");
102        Ok(())
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn a_notification_carries_actions_with_stable_ids() {
112        let notification = Notification::new("Pull request ready")
113            .with_action(NotificationAction::new("open", "Open"))
114            .with_action(NotificationAction::new("mark-read", "Mark as read"));
115
116        assert_eq!(notification.actions.len(), 2);
117        assert_eq!(notification.actions[0].id, "open");
118        assert_eq!(notification.actions[1].id, "mark-read");
119    }
120
121    #[test]
122    fn a_plain_notification_has_no_actions() {
123        assert!(Notification::new("Ping").actions.is_empty());
124    }
125}