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.
35///
36/// A new authenticated incoming chat can also notify without a first message.
37/// Its runtime marker honors the separate new-chat preference, mute, block,
38/// and focus rules. A coalesced first message uses the ordinary message notice.
39pub fn decide_notifications(
40    previous_chats: &[ChatThreadSnapshot],
41    next_chats: &[ChatThreadSnapshot],
42    preferences: &PreferencesSnapshot,
43    app_foreground: bool,
44    open_chat_id: Option<&str>,
45) -> Vec<NotificationCandidate> {
46    if !preferences.desktop_notifications_enabled {
47        return Vec::new();
48    }
49
50    let previous_by_id: HashMap<&str, &ChatThreadSnapshot> = previous_chats
51        .iter()
52        .map(|c| (c.chat_id.as_str(), c))
53        .collect();
54
55    let suppressing_open_chat = if app_foreground { open_chat_id } else { None };
56
57    let mut out = Vec::new();
58    for chat in next_chats {
59        if suppressing_open_chat == Some(chat.chat_id.as_str()) {
60            continue;
61        }
62        let previous = previous_by_id.get(chat.chat_id.as_str()).copied();
63        if preferences.invite_acceptance_notifications_enabled
64            && !chat.is_muted
65            && !preferences.blocked_owner_pubkeys.contains(&chat.chat_id)
66            && chat.new_chat_notification_id.is_some()
67            && chat.new_chat_notification_id
68                != previous.and_then(|old| old.new_chat_notification_id.clone())
69            && chat.unread_count <= previous.map_or(0, |old| old.unread_count)
70        {
71            out.push(NotificationCandidate {
72                chat_id: chat.chat_id.clone(),
73                // An exact control ID prevents read-message cleanup from
74                // immediately deleting a chat with zero unread messages.
75                message_id: chat
76                    .new_chat_notification_id
77                    .as_ref()
78                    .map(|id| format!("invite:{id}")),
79                title: "New chat".into(),
80                body: String::new(),
81            });
82            continue;
83        }
84        let mention = if chat.is_muted {
85            if chat.kind != crate::state::ChatKind::Group
86                || preferences.silent_mention_chat_ids.contains(&chat.chat_id)
87            {
88                continue;
89            }
90            let mention = chat.unread_mention_messages.iter().rev().find(|mention| {
91                !previous.is_some_and(|old| {
92                    old.unread_mention_messages
93                        .iter()
94                        .any(|item| item.message_id == mention.message_id)
95                })
96            });
97            let Some(mention) = mention else {
98                continue;
99            };
100            Some(mention)
101        } else {
102            if chat.unread_count <= previous.map_or(0, |chat| chat.unread_count) {
103                continue;
104            }
105            None
106        };
107        if mention.is_none() && chat.last_message_is_outgoing != Some(false) {
108            continue;
109        }
110        let body = mention
111            .map(|mention| mention.preview.as_str())
112            .or(chat.last_message_preview.as_deref())
113            .map(str::trim)
114            .filter(|s| !s.is_empty())
115            .map(str::to_owned)
116            .unwrap_or_else(|| "New message".to_string());
117        out.push(NotificationCandidate {
118            chat_id: chat.chat_id.clone(),
119            message_id: mention
120                .map(|mention| mention.message_id.clone())
121                .or_else(|| chat.last_message_id.clone()),
122            title: chat.display_name.clone(),
123            body,
124        });
125    }
126    out
127}
128
129/// Pull the topmost chat id out of a router stack, falling back to the
130/// default screen. Mirrors what every shell does inline so they don't
131/// each have to reach into `Screen::Chat` themselves.
132pub fn active_chat_id(router: &Router) -> Option<String> {
133    if let Some(id) = router.screen_stack.iter().rev().find_map(screen_chat_id) {
134        return Some(id);
135    }
136    screen_chat_id(&router.default_screen)
137}
138
139fn screen_chat_id(screen: &Screen) -> Option<String> {
140    match screen {
141        Screen::Chat { chat_id } => Some(chat_id.clone()),
142        _ => None,
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::state::ChatKind;
150
151    fn chat(
152        id: &str,
153        unread: u64,
154        last_is_outgoing: Option<bool>,
155        muted: bool,
156        preview: Option<&str>,
157    ) -> ChatThreadSnapshot {
158        ChatThreadSnapshot {
159            new_chat_notification_id: None,
160            social_connection: None,
161            chat_id: id.to_string(),
162            kind: ChatKind::Direct,
163            display_name: format!("name-{id}"),
164            nickname: None,
165            contact_note: None,
166            profile_name: None,
167            subtitle: None,
168            picture_url: None,
169            about: None,
170            member_count: 0,
171            last_message_preview: preview.map(str::to_string),
172            last_message_at_secs: Some(100),
173            last_message_is_outgoing: last_is_outgoing,
174            last_message_id: None,
175            unread_mention_messages: Vec::new(),
176            last_message_delivery: None,
177            unread_count: unread,
178            is_typing: false,
179            is_muted: muted,
180            is_pinned: false,
181            draft: String::new(),
182            is_request: false,
183        }
184    }
185
186    fn prefs(enabled: bool) -> PreferencesSnapshot {
187        PreferencesSnapshot {
188            desktop_notifications_enabled: enabled,
189            ..PreferencesSnapshot::default()
190        }
191    }
192
193    #[test]
194    fn new_chat_notice_respects_preferences_focus_and_replay_without_an_unread_message() {
195        let mut next = chat("new", 0, None, false, None);
196        next.new_chat_notification_id = Some("accepted".into());
197        let out = decide_notifications(&[], &[next.clone()], &prefs(true), false, None);
198        assert_eq!(out.len(), 1);
199        assert_eq!(out[0].title, "New chat");
200        assert!(out[0].body.is_empty());
201        assert_eq!(out[0].message_id.as_deref(), Some("invite:accepted"));
202        assert!(
203            decide_notifications(&[next.clone()], &[next.clone()], &prefs(true), false, None)
204                .is_empty()
205        );
206        assert!(decide_notifications(&[], &[next.clone()], &prefs(false), false, None).is_empty());
207        let mut disabled = prefs(true);
208        disabled.invite_acceptance_notifications_enabled = false;
209        assert!(decide_notifications(&[], &[next.clone()], &disabled, false, None).is_empty());
210        assert!(
211            decide_notifications(&[], &[next.clone()], &prefs(true), true, Some("new")).is_empty()
212        );
213        assert_eq!(
214            decide_notifications(&[], &[next.clone()], &prefs(true), false, Some("new")).len(),
215            1
216        );
217        next.is_muted = true;
218        assert!(decide_notifications(&[], &[next.clone()], &prefs(true), false, None).is_empty());
219        next.is_muted = false;
220        next.unread_count = 1;
221        next.last_message_is_outgoing = Some(false);
222        next.last_message_preview = Some("Hello".into());
223        let out = decide_notifications(&[], &[next], &prefs(true), false, None);
224        assert_eq!(out.len(), 1);
225        assert_eq!(
226            out[0].body, "Hello",
227            "coalesced first message replaces the generic chat notice"
228        );
229    }
230
231    #[test]
232    fn fires_when_unread_strictly_increases() {
233        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
234        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
235        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
236        assert_eq!(out.len(), 1);
237        assert_eq!(out[0].chat_id, "a");
238        assert_eq!(out[0].body, "hi");
239    }
240
241    #[test]
242    fn falls_back_to_new_message_when_preview_empty() {
243        let prev = vec![chat("a", 0, Some(false), false, None)];
244        let next = vec![chat("a", 1, Some(false), false, Some("   "))];
245        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
246        assert_eq!(out[0].body, "New message");
247    }
248
249    #[test]
250    fn suppresses_muted_chat() {
251        let prev = vec![chat("a", 0, Some(false), true, Some("hi"))];
252        let next = vec![chat("a", 1, Some(false), true, Some("hi"))];
253        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
254        assert!(out.is_empty());
255    }
256
257    #[test]
258    fn mentions_only_override_muted_groups_with_notifications_enabled() {
259        let mut next = chat("group:friends", 1, Some(false), true, Some("Hi @Alex"));
260        next.unread_mention_messages
261            .push(crate::state::MentionNotificationSnapshot {
262                message_id: "mention".into(),
263                preview: "Hi @Alex".into(),
264            });
265        assert!(
266            decide_notifications(&[], &[next.clone()], &prefs(true), false, None).is_empty(),
267            "direct chats stay muted"
268        );
269        next.kind = crate::state::ChatKind::Group;
270        assert_eq!(
271            decide_notifications(&[], &[next.clone()], &prefs(true), false, None).len(),
272            1
273        );
274        let mut opted_out = prefs(true);
275        opted_out.silent_mention_chat_ids.push(next.chat_id.clone());
276        assert!(decide_notifications(&[], &[next.clone()], &opted_out, false, None).is_empty());
277        assert!(decide_notifications(&[], &[next.clone()], &prefs(false), false, None).is_empty());
278        assert!(decide_notifications(
279            &[],
280            &[next.clone()],
281            &prefs(true),
282            true,
283            Some(&next.chat_id)
284        )
285        .is_empty());
286        next.last_message_is_outgoing = Some(true);
287        assert_eq!(
288            decide_notifications(&[], &[next.clone()], &prefs(true), false, None).len(),
289            1,
290            "a later outgoing message cannot hide a new incoming mention"
291        );
292        next.unread_mention_messages.clear();
293        assert!(decide_notifications(&[], &[next], &prefs(true), false, None).is_empty());
294    }
295
296    #[test]
297    fn suppresses_outgoing_message() {
298        let prev = vec![chat("a", 0, Some(true), false, Some("hi"))];
299        let next = vec![chat("a", 1, Some(true), false, Some("hi"))];
300        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
301        assert!(out.is_empty());
302    }
303
304    #[test]
305    fn suppresses_unknown_direction_message() {
306        let prev = vec![chat("a", 0, None, false, Some("hi"))];
307        let next = vec![chat("a", 1, None, false, Some("hi"))];
308        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
309        assert!(out.is_empty());
310    }
311
312    #[test]
313    fn suppresses_when_chat_open_and_app_foreground() {
314        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
315        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
316        let out = decide_notifications(&prev, &next, &prefs(true), true, Some("a"));
317        assert!(out.is_empty());
318    }
319
320    #[test]
321    fn fires_when_chat_open_but_app_backgrounded() {
322        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
323        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
324        let out = decide_notifications(&prev, &next, &prefs(true), false, Some("a"));
325        assert_eq!(out.len(), 1);
326    }
327
328    #[test]
329    fn fires_when_app_foreground_but_a_different_chat_open() {
330        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
331        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
332        let out = decide_notifications(&prev, &next, &prefs(true), true, Some("b"));
333        assert_eq!(out.len(), 1);
334    }
335
336    #[test]
337    fn suppresses_when_unread_unchanged_or_decreased() {
338        let prev = vec![chat("a", 2, Some(false), false, Some("hi"))];
339        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
340        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
341        assert!(out.is_empty());
342    }
343
344    #[test]
345    fn returns_empty_when_preference_disabled() {
346        let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
347        let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
348        let out = decide_notifications(&prev, &next, &prefs(false), false, None);
349        assert!(out.is_empty());
350    }
351
352    #[test]
353    fn treats_new_chat_as_previous_unread_zero() {
354        let prev: Vec<ChatThreadSnapshot> = Vec::new();
355        let next = vec![chat("a", 1, Some(false), false, Some("hello"))];
356        let out = decide_notifications(&prev, &next, &prefs(true), false, None);
357        assert_eq!(out.len(), 1);
358    }
359
360    #[test]
361    fn active_chat_id_returns_topmost_chat_screen() {
362        let router = Router {
363            default_screen: Screen::ChatList,
364            screen_stack: vec![Screen::Chat {
365                chat_id: "x".to_string(),
366            }],
367        };
368        assert_eq!(active_chat_id(&router), Some("x".to_string()));
369    }
370
371    #[test]
372    fn active_chat_id_falls_back_to_default_screen() {
373        let router = Router {
374            default_screen: Screen::Chat {
375                chat_id: "default-x".to_string(),
376            },
377            screen_stack: vec![],
378        };
379        assert_eq!(active_chat_id(&router), Some("default-x".to_string()));
380    }
381
382    #[test]
383    fn active_chat_id_none_when_not_on_a_chat_screen() {
384        let router = Router {
385            default_screen: Screen::ChatList,
386            screen_stack: vec![Screen::Settings],
387        };
388        assert_eq!(active_chat_id(&router), None);
389    }
390}