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 resolves encrypted events against persisted state in its service
7//! extension. Its notification-filtering entitlement permits suppression; the
8//! background resolver applies the same mention exception after decryption.
9
10use std::collections::HashMap;
11
12use crate::state::{ChatThreadSnapshot, PreferencesSnapshot, Router, Screen};
13
14#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
15pub struct NotificationCandidate {
16    pub chat_id: String,
17    #[uniffi(default = None)]
18    pub message_id: Option<String>,
19    pub title: String,
20    pub body: String,
21}
22
23/// Decide which chats should raise a notification given the previous and
24/// next snapshots of the chat list. Suppression rules — all must pass:
25///
26/// 1. `preferences.desktop_notifications_enabled` is true.
27/// 2. The chat is not muted, or it is a group mentioning this account and the
28///    user permits mention notifications while muted.
29/// 3. The ordinary candidate is known-incoming, or a muted group has a new
30///    unread incoming mention ID. A later ordinary/outgoing message in the
31///    same coalesced snapshot cannot hide that mention.
32/// 4. The chat is not currently open with the app foregrounded.
33/// 5. For ordinary chats, `unread_count` strictly increased. Muted group
34///    mentions compare exact unread IDs, so concurrent reads cannot hide them.
35pub fn decide_notifications(
36    previous_chats: &[ChatThreadSnapshot],
37    next_chats: &[ChatThreadSnapshot],
38    preferences: &PreferencesSnapshot,
39    app_foreground: bool,
40    open_chat_id: Option<&str>,
41) -> Vec<NotificationCandidate> {
42    if !preferences.desktop_notifications_enabled {
43        return Vec::new();
44    }
45
46    let previous_by_id: HashMap<&str, &ChatThreadSnapshot> = previous_chats
47        .iter()
48        .map(|c| (c.chat_id.as_str(), c))
49        .collect();
50
51    let suppressing_open_chat = if app_foreground { open_chat_id } else { None };
52
53    let mut out = Vec::new();
54    for chat in next_chats {
55        if suppressing_open_chat == Some(chat.chat_id.as_str()) {
56            continue;
57        }
58        let previous = previous_by_id.get(chat.chat_id.as_str()).copied();
59        let mention = if chat.is_muted {
60            if chat.kind != crate::state::ChatKind::Group
61                || preferences.silent_mention_chat_ids.contains(&chat.chat_id)
62            {
63                continue;
64            }
65            let mention = chat.unread_mention_messages.iter().rev().find(|mention| {
66                !previous.is_some_and(|old| {
67                    old.unread_mention_messages
68                        .iter()
69                        .any(|item| item.message_id == mention.message_id)
70                })
71            });
72            let Some(mention) = mention else {
73                continue;
74            };
75            Some(mention)
76        } else {
77            if chat.unread_count <= previous.map_or(0, |chat| chat.unread_count) {
78                continue;
79            }
80            None
81        };
82        if mention.is_none() && chat.last_message_is_outgoing != Some(false) {
83            continue;
84        }
85        let body = mention
86            .map(|mention| mention.preview.as_str())
87            .or(chat.last_message_preview.as_deref())
88            .map(str::trim)
89            .filter(|s| !s.is_empty())
90            .map(str::to_owned)
91            .unwrap_or_else(|| "New message".to_string());
92        out.push(NotificationCandidate {
93            chat_id: chat.chat_id.clone(),
94            message_id: mention
95                .map(|mention| mention.message_id.clone())
96                .or_else(|| chat.last_message_id.clone()),
97            title: chat.display_name.clone(),
98            body,
99        });
100    }
101    out
102}
103
104/// Pull the topmost chat id out of a router stack, falling back to the
105/// default screen. Mirrors what every shell does inline so they don't
106/// each have to reach into `Screen::Chat` themselves.
107pub fn active_chat_id(router: &Router) -> Option<String> {
108    if let Some(id) = router.screen_stack.iter().rev().find_map(screen_chat_id) {
109        return Some(id);
110    }
111    screen_chat_id(&router.default_screen)
112}
113
114fn screen_chat_id(screen: &Screen) -> Option<String> {
115    match screen {
116        Screen::Chat { chat_id } => Some(chat_id.clone()),
117        _ => None,
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::state::ChatKind;
125
126    fn chat(
127        id: &str,
128        unread: u64,
129        last_is_outgoing: Option<bool>,
130        muted: bool,
131        preview: Option<&str>,
132    ) -> ChatThreadSnapshot {
133        ChatThreadSnapshot {
134            social_connection: None,
135            chat_id: id.to_string(),
136            kind: ChatKind::Direct,
137            display_name: format!("name-{id}"),
138            nickname: None,
139            contact_note: None,
140            profile_name: None,
141            subtitle: None,
142            picture_url: None,
143            about: None,
144            member_count: 0,
145            last_message_preview: preview.map(str::to_string),
146            last_message_at_secs: Some(100),
147            last_message_is_outgoing: last_is_outgoing,
148            last_message_id: None,
149            unread_mention_messages: Vec::new(),
150            last_message_delivery: None,
151            unread_count: unread,
152            is_typing: false,
153            is_muted: muted,
154            is_pinned: false,
155            draft: String::new(),
156            is_request: false,
157        }
158    }
159
160    fn prefs(enabled: bool) -> PreferencesSnapshot {
161        PreferencesSnapshot {
162            desktop_notifications_enabled: enabled,
163            ..PreferencesSnapshot::default()
164        }
165    }
166
167    #[test]
168    fn fires_when_unread_strictly_increases() {
169        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
170        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
171        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
172        assert_eq!(out.len(), 1);
173        assert_eq!(out[0].chat_id, "a");
174        assert_eq!(out[0].body, "hi");
175    }
176
177    #[test]
178    fn falls_back_to_new_message_when_preview_empty() {
179        let prev = vec![chat("a", 0, Some(false), false, None)];
180        let next = vec![chat("a", 1, Some(false), false, Some("   "))];
181        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
182        assert_eq!(out[0].body, "New message");
183    }
184
185    #[test]
186    fn suppresses_muted_chat() {
187        let prev = vec![chat("a", 0, Some(false), true, Some("hi"))];
188        let next = vec![chat("a", 1, Some(false), true, Some("hi"))];
189        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
190        assert!(out.is_empty());
191    }
192
193    #[test]
194    fn mentions_only_override_muted_groups_with_notifications_enabled() {
195        let mut next = chat("group:friends", 1, Some(false), true, Some("Hi @Alex"));
196        next.unread_mention_messages
197            .push(crate::state::MentionNotificationSnapshot {
198                message_id: "mention".into(),
199                preview: "Hi @Alex".into(),
200            });
201        assert!(
202            decide_notifications(&[], &[next.clone()], &prefs(true), false, None).is_empty(),
203            "direct chats stay muted"
204        );
205        next.kind = crate::state::ChatKind::Group;
206        assert_eq!(
207            decide_notifications(&[], &[next.clone()], &prefs(true), false, None).len(),
208            1
209        );
210        let mut opted_out = prefs(true);
211        opted_out.silent_mention_chat_ids.push(next.chat_id.clone());
212        assert!(decide_notifications(&[], &[next.clone()], &opted_out, false, None).is_empty());
213        assert!(decide_notifications(&[], &[next.clone()], &prefs(false), false, None).is_empty());
214        assert!(decide_notifications(
215            &[],
216            &[next.clone()],
217            &prefs(true),
218            true,
219            Some(&next.chat_id)
220        )
221        .is_empty());
222        next.last_message_is_outgoing = Some(true);
223        assert_eq!(
224            decide_notifications(&[], &[next.clone()], &prefs(true), false, None).len(),
225            1,
226            "a later outgoing message cannot hide a new incoming mention"
227        );
228        next.unread_mention_messages.clear();
229        assert!(decide_notifications(&[], &[next], &prefs(true), false, None).is_empty());
230    }
231
232    #[test]
233    fn suppresses_outgoing_message() {
234        let prev = vec![chat("a", 0, Some(true), false, Some("hi"))];
235        let next = vec![chat("a", 1, Some(true), false, Some("hi"))];
236        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
237        assert!(out.is_empty());
238    }
239
240    #[test]
241    fn suppresses_unknown_direction_message() {
242        let prev = vec![chat("a", 0, None, false, Some("hi"))];
243        let next = vec![chat("a", 1, None, false, Some("hi"))];
244        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
245        assert!(out.is_empty());
246    }
247
248    #[test]
249    fn suppresses_when_chat_open_and_app_foreground() {
250        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
251        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
252        let out = decide_notifications(&prev, &next, &prefs(true), true, Some("a"));
253        assert!(out.is_empty());
254    }
255
256    #[test]
257    fn fires_when_chat_open_but_app_backgrounded() {
258        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
259        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
260        let out = decide_notifications(&prev, &next, &prefs(true), false, Some("a"));
261        assert_eq!(out.len(), 1);
262    }
263
264    #[test]
265    fn fires_when_app_foreground_but_a_different_chat_open() {
266        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
267        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
268        let out = decide_notifications(&prev, &next, &prefs(true), true, Some("b"));
269        assert_eq!(out.len(), 1);
270    }
271
272    #[test]
273    fn suppresses_when_unread_unchanged_or_decreased() {
274        let prev = vec![chat("a", 2, Some(false), false, Some("hi"))];
275        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
276        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
277        assert!(out.is_empty());
278    }
279
280    #[test]
281    fn returns_empty_when_preference_disabled() {
282        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
283        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
284        let out = decide_notifications(&prev, &next, &prefs(false), false, None);
285        assert!(out.is_empty());
286    }
287
288    #[test]
289    fn treats_new_chat_as_previous_unread_zero() {
290        let prev: Vec<ChatThreadSnapshot> = Vec::new();
291        let next = vec![chat("a", 1, Some(false), false, Some("hello"))];
292        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
293        assert_eq!(out.len(), 1);
294    }
295
296    #[test]
297    fn active_chat_id_returns_topmost_chat_screen() {
298        let router = Router {
299            default_screen: Screen::ChatList,
300            screen_stack: vec![Screen::Chat {
301                chat_id: "x".to_string(),
302            }],
303        };
304        assert_eq!(active_chat_id(&router), Some("x".to_string()));
305    }
306
307    #[test]
308    fn active_chat_id_falls_back_to_default_screen() {
309        let router = Router {
310            default_screen: Screen::Chat {
311                chat_id: "default-x".to_string(),
312            },
313            screen_stack: vec![],
314        };
315        assert_eq!(active_chat_id(&router), Some("default-x".to_string()));
316    }
317
318    #[test]
319    fn active_chat_id_none_when_not_on_a_chat_screen() {
320        let router = Router {
321            default_screen: Screen::ChatList,
322            screen_stack: vec![Screen::Settings],
323        };
324        assert_eq!(active_chat_id(&router), None);
325    }
326}