Skip to main content

iris_chat_core/core/
notifications.rs

1//! Single source of truth for "should this chat raise a desktop / shell
2//! notification?". All non-iOS-APNS callers (foreground macOS, Linux GTK,
3//! Windows WPF, Android in-app) compare two consecutive AppState snapshots
4//! and call [`decide_notifications`] to get the resulting candidate list.
5//!
6//! iOS APNS keeps its own minimal suppression path
7//! (`mobile_push::decrypt_mobile_push_notification`) because background
8//! Notification Service Extensions cannot reach the live AppState and,
9//! more importantly, until we ship the
10//! `com.apple.developer.usernotifications.filtering` entitlement, Apple
11//! requires every delivered push to surface to the user. Do not route
12//! iOS APNS through this module.
13
14use std::collections::HashMap;
15
16use crate::state::{ChatThreadSnapshot, PreferencesSnapshot, Router, Screen};
17
18#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
19pub struct NotificationCandidate {
20    pub chat_id: String,
21    pub title: String,
22    pub body: String,
23}
24
25/// Decide which chats should raise a notification given the previous and
26/// next snapshots of the chat list. Suppression rules — all must pass:
27///
28/// 1. `preferences.desktop_notifications_enabled` is true.
29/// 2. The chat is not muted (`chat.is_muted`).
30/// 3. The last message is known-incoming (`last_message_is_outgoing == Some(false)`).
31///    Unknown direction (`None`) is treated as suppressed: we'd rather miss
32///    a banner than show one for our own outgoing message.
33/// 4. The chat is not currently open with the app foregrounded.
34/// 5. `unread_count` strictly increased relative to the previous snapshot
35///    (chats absent from the previous snapshot count as previous = 0).
36pub fn decide_notifications(
37    previous_chats: &[ChatThreadSnapshot],
38    next_chats: &[ChatThreadSnapshot],
39    preferences: &PreferencesSnapshot,
40    app_foreground: bool,
41    open_chat_id: Option<&str>,
42) -> Vec<NotificationCandidate> {
43    if !preferences.desktop_notifications_enabled {
44        return Vec::new();
45    }
46
47    let previous_unread: HashMap<&str, u64> = previous_chats
48        .iter()
49        .map(|c| (c.chat_id.as_str(), c.unread_count))
50        .collect();
51
52    let suppressing_open_chat = if app_foreground { open_chat_id } else { None };
53
54    let mut out = Vec::new();
55    for chat in next_chats {
56        if chat.is_muted {
57            continue;
58        }
59        if chat.last_message_is_outgoing != Some(false) {
60            continue;
61        }
62        if suppressing_open_chat == Some(chat.chat_id.as_str()) {
63            continue;
64        }
65        let previous = previous_unread
66            .get(chat.chat_id.as_str())
67            .copied()
68            .unwrap_or(0);
69        if chat.unread_count <= previous {
70            continue;
71        }
72        let body = chat
73            .last_message_preview
74            .as_deref()
75            .map(str::trim)
76            .filter(|s| !s.is_empty())
77            .map(str::to_owned)
78            .unwrap_or_else(|| "New message".to_string());
79        out.push(NotificationCandidate {
80            chat_id: chat.chat_id.clone(),
81            title: chat.display_name.clone(),
82            body,
83        });
84    }
85    out
86}
87
88/// Pull the topmost chat id out of a router stack, falling back to the
89/// default screen. Mirrors what every shell does inline so they don't
90/// each have to reach into `Screen::Chat` themselves.
91pub fn active_chat_id(router: &Router) -> Option<String> {
92    if let Some(id) = router.screen_stack.iter().rev().find_map(screen_chat_id) {
93        return Some(id);
94    }
95    screen_chat_id(&router.default_screen)
96}
97
98fn screen_chat_id(screen: &Screen) -> Option<String> {
99    match screen {
100        Screen::Chat { chat_id } => Some(chat_id.clone()),
101        _ => None,
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::state::ChatKind;
109
110    fn chat(
111        id: &str,
112        unread: u64,
113        last_is_outgoing: Option<bool>,
114        muted: bool,
115        preview: Option<&str>,
116    ) -> ChatThreadSnapshot {
117        ChatThreadSnapshot {
118            chat_id: id.to_string(),
119            kind: ChatKind::Direct,
120            display_name: format!("name-{id}"),
121            nickname: None,
122            profile_name: None,
123            subtitle: None,
124            picture_url: None,
125            about: None,
126            member_count: 0,
127            last_message_preview: preview.map(str::to_string),
128            last_message_at_secs: Some(100),
129            last_message_is_outgoing: last_is_outgoing,
130            last_message_delivery: None,
131            unread_count: unread,
132            is_typing: false,
133            is_muted: muted,
134            is_pinned: false,
135            draft: String::new(),
136            is_request: false,
137        }
138    }
139
140    fn prefs(enabled: bool) -> PreferencesSnapshot {
141        let mut p = PreferencesSnapshot::default();
142        p.desktop_notifications_enabled = enabled;
143        p
144    }
145
146    #[test]
147    fn fires_when_unread_strictly_increases() {
148        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
149        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
150        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
151        assert_eq!(out.len(), 1);
152        assert_eq!(out[0].chat_id, "a");
153        assert_eq!(out[0].body, "hi");
154    }
155
156    #[test]
157    fn falls_back_to_new_message_when_preview_empty() {
158        let prev = vec![chat("a", 0, Some(false), false, None)];
159        let next = vec![chat("a", 1, Some(false), false, Some("   "))];
160        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
161        assert_eq!(out[0].body, "New message");
162    }
163
164    #[test]
165    fn suppresses_muted_chat() {
166        let prev = vec![chat("a", 0, Some(false), true, Some("hi"))];
167        let next = vec![chat("a", 1, Some(false), true, Some("hi"))];
168        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
169        assert!(out.is_empty());
170    }
171
172    #[test]
173    fn suppresses_outgoing_message() {
174        let prev = vec![chat("a", 0, Some(true), false, Some("hi"))];
175        let next = vec![chat("a", 1, Some(true), false, Some("hi"))];
176        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
177        assert!(out.is_empty());
178    }
179
180    #[test]
181    fn suppresses_unknown_direction_message() {
182        let prev = vec![chat("a", 0, None, false, Some("hi"))];
183        let next = vec![chat("a", 1, None, false, Some("hi"))];
184        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
185        assert!(out.is_empty());
186    }
187
188    #[test]
189    fn suppresses_when_chat_open_and_app_foreground() {
190        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
191        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
192        let out = decide_notifications(&prev, &next, &prefs(true), true, Some("a"));
193        assert!(out.is_empty());
194    }
195
196    #[test]
197    fn fires_when_chat_open_but_app_backgrounded() {
198        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
199        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
200        let out = decide_notifications(&prev, &next, &prefs(true), false, Some("a"));
201        assert_eq!(out.len(), 1);
202    }
203
204    #[test]
205    fn fires_when_app_foreground_but_a_different_chat_open() {
206        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
207        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
208        let out = decide_notifications(&prev, &next, &prefs(true), true, Some("b"));
209        assert_eq!(out.len(), 1);
210    }
211
212    #[test]
213    fn suppresses_when_unread_unchanged_or_decreased() {
214        let prev = vec![chat("a", 2, Some(false), false, Some("hi"))];
215        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
216        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
217        assert!(out.is_empty());
218    }
219
220    #[test]
221    fn returns_empty_when_preference_disabled() {
222        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
223        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
224        let out = decide_notifications(&prev, &next, &prefs(false), false, None);
225        assert!(out.is_empty());
226    }
227
228    #[test]
229    fn treats_new_chat_as_previous_unread_zero() {
230        let prev: Vec<ChatThreadSnapshot> = Vec::new();
231        let next = vec![chat("a", 1, Some(false), false, Some("hello"))];
232        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
233        assert_eq!(out.len(), 1);
234    }
235
236    #[test]
237    fn active_chat_id_returns_topmost_chat_screen() {
238        let router = Router {
239            default_screen: Screen::ChatList,
240            screen_stack: vec![Screen::Chat {
241                chat_id: "x".to_string(),
242            }],
243        };
244        assert_eq!(active_chat_id(&router), Some("x".to_string()));
245    }
246
247    #[test]
248    fn active_chat_id_falls_back_to_default_screen() {
249        let router = Router {
250            default_screen: Screen::Chat {
251                chat_id: "default-x".to_string(),
252            },
253            screen_stack: vec![],
254        };
255        assert_eq!(active_chat_id(&router), Some("default-x".to_string()));
256    }
257
258    #[test]
259    fn active_chat_id_none_when_not_on_a_chat_screen() {
260        let router = Router {
261            default_screen: Screen::ChatList,
262            screen_stack: vec![Screen::Settings],
263        };
264        assert_eq!(active_chat_id(&router), None);
265    }
266}