1use 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
23pub 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 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
129pub 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}