Skip to main content

cranpose_services/
notifier.rs

1//! Local notifications: ask permission and post user-visible notifications.
2//!
3//! The compiled-in default is a no-op; platform backends install a real
4//! notifier through [`set_platform_notifier`] (iOS `UNUserNotificationCenter`,
5//! Android `NotificationManager`, desktop `notify-rust`, the web Notifications
6//! API). Desktop/CI without a notification service simply drop them.
7
8use cranpose_core::compositionLocalOfWithPolicy;
9use cranpose_core::CompositionLocal;
10use cranpose_core::CompositionLocalProvider;
11use cranpose_macros::composable;
12use std::cell::RefCell;
13use std::rc::Rc;
14
15/// A local notification to post.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct NotifyRequest {
18    /// Stable identifier: re-posting with the same id replaces the previous
19    /// notification (used for progress updates).
20    pub id: String,
21    pub title: String,
22    pub body: String,
23    /// Whether this is an ongoing/progress notification (best-effort; platforms
24    /// without ongoing notifications post a normal one).
25    pub ongoing: bool,
26    /// Optional deep-link payload delivered to the app when the user taps it.
27    pub deeplink: Option<String>,
28}
29
30impl NotifyRequest {
31    pub fn new(id: impl Into<String>, title: impl Into<String>, body: impl Into<String>) -> Self {
32        Self {
33            id: id.into(),
34            title: title.into(),
35            body: body.into(),
36            ongoing: false,
37            deeplink: None,
38        }
39    }
40
41    pub fn ongoing(mut self, ongoing: bool) -> Self {
42        self.ongoing = ongoing;
43        self
44    }
45
46    pub fn with_deeplink(mut self, deeplink: impl Into<String>) -> Self {
47        self.deeplink = Some(deeplink.into());
48        self
49    }
50}
51
52/// Posts local notifications. Installed by the platform backend; the default is
53/// a no-op.
54pub trait Notifier {
55    /// Request permission to show notifications (idempotent; safe to call more
56    /// than once).
57    fn request_permission(&self);
58    /// Post (or replace, by id) a local notification.
59    fn notify(&self, request: NotifyRequest);
60    /// Remove a delivered or pending notification by id.
61    fn cancel(&self, id: &str);
62}
63
64pub type NotifierRef = Rc<dyn Notifier>;
65
66struct NoopNotifier;
67
68impl Notifier for NoopNotifier {
69    fn request_permission(&self) {}
70    fn notify(&self, _request: NotifyRequest) {}
71    fn cancel(&self, _id: &str) {}
72}
73
74thread_local! {
75    static PLATFORM_NOTIFIER: RefCell<Option<NotifierRef>> = const { RefCell::new(None) };
76}
77
78/// Installs a platform notifier, replacing any previously installed one.
79pub fn set_platform_notifier(notifier: NotifierRef) {
80    PLATFORM_NOTIFIER.with(|cell| *cell.borrow_mut() = Some(notifier));
81}
82
83/// Removes any registered platform notifier (tests and teardown).
84pub fn clear_platform_notifier() {
85    PLATFORM_NOTIFIER.with(|cell| *cell.borrow_mut() = None);
86}
87
88pub fn default_notifier() -> NotifierRef {
89    PLATFORM_NOTIFIER
90        .with(|cell| cell.borrow().clone())
91        .unwrap_or_else(|| Rc::new(NoopNotifier))
92}
93
94pub fn local_notifier() -> CompositionLocal<NotifierRef> {
95    thread_local! {
96        static LOCAL_NOTIFIER: RefCell<Option<CompositionLocal<NotifierRef>>> = const { RefCell::new(None) };
97    }
98
99    LOCAL_NOTIFIER.with(|cell| {
100        let mut local = cell.borrow_mut();
101        local
102            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_notifier, Rc::ptr_eq))
103            .clone()
104    })
105}
106
107#[allow(non_snake_case)]
108#[composable]
109pub fn ProvideNotifier(content: impl FnOnce()) {
110    let notifier = cranpose_core::remember(default_notifier).with(|state| state.clone());
111    let local = local_notifier();
112    CompositionLocalProvider(vec![local.provides(notifier)], move || {
113        content();
114    });
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use std::cell::RefCell;
121
122    #[derive(Default)]
123    struct Recorder {
124        posted: RefCell<Vec<String>>,
125    }
126    impl Notifier for Recorder {
127        fn request_permission(&self) {}
128        fn notify(&self, request: NotifyRequest) {
129            self.posted.borrow_mut().push(request.id);
130        }
131        fn cancel(&self, _id: &str) {}
132    }
133
134    #[test]
135    fn default_is_noop_then_registered_takes_over() {
136        clear_platform_notifier();
137        // No panic on the no-op default.
138        default_notifier().notify(NotifyRequest::new("a", "t", "b"));
139        let rec = Rc::new(Recorder::default());
140        set_platform_notifier(rec.clone());
141        default_notifier().notify(NotifyRequest::new("done", "t", "b").with_deeplink("doc/1"));
142        assert_eq!(rec.posted.borrow().as_slice(), &["done".to_string()]);
143        clear_platform_notifier();
144    }
145}