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(
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
104pub 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}