1use std::collections::HashMap;
15
16use crate::state::{ChatThreadSnapshot, PreferencesSnapshot, Router, Screen};
17
18#[derive(uniffi::Record, Clone, Debug, PartialEq, Eq)]
19pub struct NotificationCandidate {
20 pub chat_id: String,
21 pub title: String,
22 pub body: String,
23}
24
25pub fn decide_notifications(
37 previous_chats: &[ChatThreadSnapshot],
38 next_chats: &[ChatThreadSnapshot],
39 preferences: &PreferencesSnapshot,
40 app_foreground: bool,
41 open_chat_id: Option<&str>,
42) -> Vec<NotificationCandidate> {
43 if !preferences.desktop_notifications_enabled {
44 return Vec::new();
45 }
46
47 let previous_unread: HashMap<&str, u64> = previous_chats
48 .iter()
49 .map(|c| (c.chat_id.as_str(), c.unread_count))
50 .collect();
51
52 let suppressing_open_chat = if app_foreground { open_chat_id } else { None };
53
54 let mut out = Vec::new();
55 for chat in next_chats {
56 if chat.is_muted {
57 continue;
58 }
59 if chat.last_message_is_outgoing != Some(false) {
60 continue;
61 }
62 if suppressing_open_chat == Some(chat.chat_id.as_str()) {
63 continue;
64 }
65 let previous = previous_unread
66 .get(chat.chat_id.as_str())
67 .copied()
68 .unwrap_or(0);
69 if chat.unread_count <= previous {
70 continue;
71 }
72 let body = chat
73 .last_message_preview
74 .as_deref()
75 .map(str::trim)
76 .filter(|s| !s.is_empty())
77 .map(str::to_owned)
78 .unwrap_or_else(|| "New message".to_string());
79 out.push(NotificationCandidate {
80 chat_id: chat.chat_id.clone(),
81 title: chat.display_name.clone(),
82 body,
83 });
84 }
85 out
86}
87
88pub fn active_chat_id(router: &Router) -> Option<String> {
92 if let Some(id) = router.screen_stack.iter().rev().find_map(screen_chat_id) {
93 return Some(id);
94 }
95 screen_chat_id(&router.default_screen)
96}
97
98fn screen_chat_id(screen: &Screen) -> Option<String> {
99 match screen {
100 Screen::Chat { chat_id } => Some(chat_id.clone()),
101 _ => None,
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::state::ChatKind;
109
110 fn chat(
111 id: &str,
112 unread: u64,
113 last_is_outgoing: Option<bool>,
114 muted: bool,
115 preview: Option<&str>,
116 ) -> ChatThreadSnapshot {
117 ChatThreadSnapshot {
118 chat_id: id.to_string(),
119 kind: ChatKind::Direct,
120 display_name: format!("name-{id}"),
121 nickname: None,
122 profile_name: None,
123 subtitle: None,
124 picture_url: None,
125 about: None,
126 member_count: 0,
127 last_message_preview: preview.map(str::to_string),
128 last_message_at_secs: Some(100),
129 last_message_is_outgoing: last_is_outgoing,
130 last_message_delivery: None,
131 unread_count: unread,
132 is_typing: false,
133 is_muted: muted,
134 is_pinned: false,
135 draft: String::new(),
136 is_request: false,
137 }
138 }
139
140 fn prefs(enabled: bool) -> PreferencesSnapshot {
141 let mut p = PreferencesSnapshot::default();
142 p.desktop_notifications_enabled = enabled;
143 p
144 }
145
146 #[test]
147 fn fires_when_unread_strictly_increases() {
148 let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
149 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
150 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
151 assert_eq!(out.len(), 1);
152 assert_eq!(out[0].chat_id, "a");
153 assert_eq!(out[0].body, "hi");
154 }
155
156 #[test]
157 fn falls_back_to_new_message_when_preview_empty() {
158 let prev = vec![chat("a", 0, Some(false), false, None)];
159 let next = vec![chat("a", 1, Some(false), false, Some(" "))];
160 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
161 assert_eq!(out[0].body, "New message");
162 }
163
164 #[test]
165 fn suppresses_muted_chat() {
166 let prev = vec![chat("a", 0, Some(false), true, Some("hi"))];
167 let next = vec![chat("a", 1, Some(false), true, Some("hi"))];
168 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
169 assert!(out.is_empty());
170 }
171
172 #[test]
173 fn suppresses_outgoing_message() {
174 let prev = vec![chat("a", 0, Some(true), false, Some("hi"))];
175 let next = vec![chat("a", 1, Some(true), false, Some("hi"))];
176 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
177 assert!(out.is_empty());
178 }
179
180 #[test]
181 fn suppresses_unknown_direction_message() {
182 let prev = vec![chat("a", 0, None, false, Some("hi"))];
183 let next = vec![chat("a", 1, None, false, Some("hi"))];
184 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
185 assert!(out.is_empty());
186 }
187
188 #[test]
189 fn suppresses_when_chat_open_and_app_foreground() {
190 let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
191 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
192 let out = decide_notifications(&prev, &next, &prefs(true), true, Some("a"));
193 assert!(out.is_empty());
194 }
195
196 #[test]
197 fn fires_when_chat_open_but_app_backgrounded() {
198 let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
199 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
200 let out = decide_notifications(&prev, &next, &prefs(true), false, Some("a"));
201 assert_eq!(out.len(), 1);
202 }
203
204 #[test]
205 fn fires_when_app_foreground_but_a_different_chat_open() {
206 let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
207 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
208 let out = decide_notifications(&prev, &next, &prefs(true), true, Some("b"));
209 assert_eq!(out.len(), 1);
210 }
211
212 #[test]
213 fn suppresses_when_unread_unchanged_or_decreased() {
214 let prev = vec![chat("a", 2, Some(false), false, Some("hi"))];
215 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
216 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
217 assert!(out.is_empty());
218 }
219
220 #[test]
221 fn returns_empty_when_preference_disabled() {
222 let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
223 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
224 let out = decide_notifications(&prev, &next, &prefs(false), false, None);
225 assert!(out.is_empty());
226 }
227
228 #[test]
229 fn treats_new_chat_as_previous_unread_zero() {
230 let prev: Vec<ChatThreadSnapshot> = Vec::new();
231 let next = vec![chat("a", 1, Some(false), false, Some("hello"))];
232 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
233 assert_eq!(out.len(), 1);
234 }
235
236 #[test]
237 fn active_chat_id_returns_topmost_chat_screen() {
238 let router = Router {
239 default_screen: Screen::ChatList,
240 screen_stack: vec![Screen::Chat {
241 chat_id: "x".to_string(),
242 }],
243 };
244 assert_eq!(active_chat_id(&router), Some("x".to_string()));
245 }
246
247 #[test]
248 fn active_chat_id_falls_back_to_default_screen() {
249 let router = Router {
250 default_screen: Screen::Chat {
251 chat_id: "default-x".to_string(),
252 },
253 screen_stack: vec![],
254 };
255 assert_eq!(active_chat_id(&router), Some("default-x".to_string()));
256 }
257
258 #[test]
259 fn active_chat_id_none_when_not_on_a_chat_screen() {
260 let router = Router {
261 default_screen: Screen::ChatList,
262 screen_stack: vec![Screen::Settings],
263 };
264 assert_eq!(active_chat_id(&router), None);
265 }
266}