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