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 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 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
139pub 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}