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
66use std::sync::Mutex;
67use std::sync::OnceLock;
68
69fn pending_deeplink_slot() -> &'static Mutex<Option<String>> {
70    static SLOT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
71    SLOT.get_or_init(|| Mutex::new(None))
72}
73
74/// Record the deep-link payload of a notification the user just tapped. Called
75/// by the platform backend's notification delegate; the app drains it via
76/// [`take_notification_deeplink`].
77pub fn push_notification_deeplink(link: String) {
78    if let Ok(mut slot) = pending_deeplink_slot().lock() {
79        *slot = Some(link);
80    }
81}
82
83/// Take (and clear) the deep-link payload of the most recently tapped
84/// notification, if any. Polled by the app's deep-link handling.
85pub fn take_notification_deeplink() -> Option<String> {
86    pending_deeplink_slot()
87        .lock()
88        .ok()
89        .and_then(|mut s| s.take())
90}
91
92struct NoopNotifier;
93
94impl Notifier for NoopNotifier {
95    fn request_permission(&self) {}
96    fn notify(&self, _request: NotifyRequest) {}
97    fn cancel(&self, _id: &str) {}
98}
99
100thread_local! {
101    static PLATFORM_NOTIFIER: RefCell<Option<NotifierRef>> = const { RefCell::new(None) };
102}
103
104/// Installs a platform notifier, replacing any previously installed one.
105pub fn set_platform_notifier(notifier: NotifierRef) {
106    PLATFORM_NOTIFIER.with(|cell| *cell.borrow_mut() = Some(notifier));
107}
108
109/// Removes any registered platform notifier (tests and teardown).
110pub fn clear_platform_notifier() {
111    PLATFORM_NOTIFIER.with(|cell| *cell.borrow_mut() = None);
112}
113
114pub fn default_notifier() -> NotifierRef {
115    PLATFORM_NOTIFIER
116        .with(|cell| cell.borrow().clone())
117        .unwrap_or_else(|| Rc::new(NoopNotifier))
118}
119
120pub fn local_notifier() -> CompositionLocal<NotifierRef> {
121    thread_local! {
122        static LOCAL_NOTIFIER: RefCell<Option<CompositionLocal<NotifierRef>>> = const { RefCell::new(None) };
123    }
124
125    LOCAL_NOTIFIER.with(|cell| {
126        let mut local = cell.borrow_mut();
127        local
128            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_notifier, Rc::ptr_eq))
129            .clone()
130    })
131}
132
133#[allow(non_snake_case)]
134#[composable]
135pub fn ProvideNotifier(content: impl FnOnce()) {
136    let notifier = cranpose_core::remember(default_notifier).with(|state| state.clone());
137    let local = local_notifier();
138    CompositionLocalProvider(vec![local.provides(notifier)], move || {
139        content();
140    });
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use std::cell::RefCell;
147
148    #[derive(Default)]
149    struct Recorder {
150        posted: RefCell<Vec<String>>,
151    }
152    impl Notifier for Recorder {
153        fn request_permission(&self) {}
154        fn notify(&self, request: NotifyRequest) {
155            self.posted.borrow_mut().push(request.id);
156        }
157        fn cancel(&self, _id: &str) {}
158    }
159
160    #[test]
161    fn default_is_noop_then_registered_takes_over() {
162        clear_platform_notifier();
163        // No panic on the no-op default.
164        default_notifier().notify(NotifyRequest::new("a", "t", "b"));
165        let rec = Rc::new(Recorder::default());
166        set_platform_notifier(rec.clone());
167        default_notifier().notify(NotifyRequest::new("done", "t", "b").with_deeplink("doc/1"));
168        assert_eq!(rec.posted.borrow().as_slice(), &["done".to_string()]);
169        clear_platform_notifier();
170    }
171}