mac_usernotifications/response.rs
1//! The response a user gave to a notification.
2//!
3//! Delivered by the delegate after the user interacts with a notification.
4//! Callers receive this via the `Future` returned from `send_with_actions`.
5
6use std::sync::OnceLock;
7
8use objc2_user_notifications::{
9 UNNotificationDefaultActionIdentifier, UNNotificationDismissActionIdentifier,
10};
11
12/// Why a notification was closed without an explicit user action.
13///
14/// Carried by [`NotificationResponse::close_reason`]. A `None` value means the
15/// user took an active action (clicked the body, pressed a button, or submitted
16/// a reply).
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum CloseReason {
19 /// The notification timed out and was removed by this crate.
20 ///
21 /// Produced when the duration set via
22 /// [`Notification::timeout`](crate::Notification::timeout) elapses.
23 Expired,
24
25 /// The user explicitly dismissed the notification (e.g. swiped it away
26 /// or pressed "Clear" in Notification Center).
27 Dismissed,
28}
29
30/// What the user did with a notification.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct NotificationResponse {
33 /// The unique identifier of the notification this response belongs to.
34 ///
35 /// This is the same identifier that can be passed to [`close_delivered`] or
36 /// [`cancel_pending`]. Useful when the caller did not set an explicit ID via
37 /// [`Notification::id`] and therefore did not know the auto-generated UUID.
38 ///
39 /// [`close_delivered`]: crate::close_delivered
40 /// [`cancel_pending`]: crate::cancel_pending
41 /// [`Notification::id`]: crate::Notification::id
42 pub notification_id: String,
43
44 /// The action identifier the user chose.
45 ///
46 /// Use [`is_default_action`] and [`is_dismiss_action`] for built-in cases,
47 /// or compare directly with custom action identifiers.
48 ///
49 /// [`is_default_action`]: NotificationResponse::is_default_action
50 /// [`is_dismiss_action`]: NotificationResponse::is_dismiss_action
51 pub action_identifier: String,
52
53 /// Text entered in a reply action, `None` for button actions, default-action, and dismiss.
54 ///
55 /// Use [`is_reply`] to check, or call `.reply_text.as_deref()` to borrow as `&str`.
56 ///
57 /// [`is_reply`]: NotificationResponse::is_reply
58 pub reply_text: Option<String>,
59
60 /// Why the notification was closed passively, or `None` if the user took
61 /// an explicit action.
62 ///
63 /// Check [`CloseReason::Expired`] to detect a timeout auto-close, or
64 /// [`CloseReason::Dismissed`] for a user-initiated swipe/clear.
65 pub close_reason: Option<CloseReason>,
66}
67
68/// Returns the dismiss action identifier string, cached for the process lifetime.
69fn dismiss_action_id() -> &'static str {
70 static CACHED: OnceLock<String> = OnceLock::new();
71 CACHED.get_or_init(|| {
72 // SAFETY: `UNNotificationDismissActionIdentifier` is a valid, non-null
73 // `NSString` backed by a well-known Apple framework constant that lives
74 // for the lifetime of the process.
75 unsafe { UNNotificationDismissActionIdentifier.to_string() }
76 })
77}
78
79/// Returns the default action identifier string, cached for the process lifetime.
80fn default_action_id() -> &'static str {
81 static CACHED: OnceLock<String> = OnceLock::new();
82 CACHED.get_or_init(|| {
83 // SAFETY: same rationale as `dismiss_action_id`.
84 unsafe { UNNotificationDefaultActionIdentifier.to_string() }
85 })
86}
87
88impl NotificationResponse {
89 /// Build a response from raw macOS delegate data.
90 pub(crate) fn from_objc(
91 notification_id: String,
92 action_id: String,
93 reply_text: Option<String>,
94 ) -> Self {
95 let close_reason = if action_id == dismiss_action_id() {
96 Some(CloseReason::Dismissed)
97 } else {
98 None
99 };
100 Self {
101 notification_id,
102 action_identifier: action_id,
103 reply_text,
104 close_reason,
105 }
106 }
107
108 /// Construct a synthetic timed-out response for a notification with the given ID.
109 ///
110 /// Used internally when the caller's timeout elapses and the notification
111 /// is removed via [`close_delivered`](crate::close_delivered).
112 pub(crate) fn timed_out(notification_id: String) -> Self {
113 Self {
114 notification_id,
115 action_identifier: String::new(),
116 reply_text: None,
117 close_reason: Some(CloseReason::Expired),
118 }
119 }
120
121 /// Construct a synthetic dismissed response, used by the poll-based dismiss
122 /// fallback for buttonless notifications.
123 pub(crate) fn dismissed(notification_id: String) -> Self {
124 Self {
125 notification_id,
126 action_identifier: String::new(),
127 reply_text: None,
128 close_reason: Some(CloseReason::Dismissed),
129 }
130 }
131
132 /// Returns `true` if the notification was automatically closed because the
133 /// timeout set via [`Notification::timeout`](crate::Notification::timeout) elapsed.
134 ///
135 /// When this returns `true` the notification banner has already been removed
136 /// from the screen by the time the future resolves.
137 pub fn is_timed_out(&self) -> bool {
138 self.close_reason == Some(CloseReason::Expired)
139 }
140
141 /// Returns `true` if the user clicked the notification body (default action).
142 ///
143 /// Corresponds to [`UNNotificationDefaultActionIdentifier`](https://developer.apple.com/documentation/usernotifications/unnotificationdefaultactionidentifier).
144 pub fn is_default_action(&self) -> bool {
145 self.action_identifier == default_action_id()
146 }
147
148 /// Returns `true` if the user dismissed the notification.
149 ///
150 /// Corresponds to [`UNNotificationDismissActionIdentifier`](https://developer.apple.com/documentation/usernotifications/unnotificationdismissactionidentifier).
151 pub fn is_dismiss_action(&self) -> bool {
152 self.close_reason == Some(CloseReason::Dismissed)
153 }
154
155 /// Returns `true` if the user submitted text via a reply action.
156 pub fn is_reply(&self) -> bool {
157 self.reply_text.is_some()
158 }
159}