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 PreferencesSnapshot {
142 desktop_notifications_enabled: enabled,
143 ..PreferencesSnapshot::default()
144 }
145 }
146
147 #[test]
148 fn fires_when_unread_strictly_increases() {
149 let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
150 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
151 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
152 assert_eq!(out.len(), 1);
153 assert_eq!(out[0].chat_id, "a");
154 assert_eq!(out[0].body, "hi");
155 }
156
157 #[test]
158 fn falls_back_to_new_message_when_preview_empty() {
159 let prev = vec![chat("a", 0, Some(false), false, None)];
160 let next = vec![chat("a", 1, Some(false), false, Some(" "))];
161 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
162 assert_eq!(out[0].body, "New message");
163 }
164
165 #[test]
166 fn suppresses_muted_chat() {
167 let prev = vec![chat("a", 0, Some(false), true, Some("hi"))];
168 let next = vec![chat("a", 1, Some(false), true, Some("hi"))];
169 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
170 assert!(out.is_empty());
171 }
172
173 #[test]
174 fn suppresses_outgoing_message() {
175 let prev = vec![chat("a", 0, Some(true), false, Some("hi"))];
176 let next = vec![chat("a", 1, Some(true), false, Some("hi"))];
177 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
178 assert!(out.is_empty());
179 }
180
181 #[test]
182 fn suppresses_unknown_direction_message() {
183 let prev = vec![chat("a", 0, None, false, Some("hi"))];
184 let next = vec![chat("a", 1, None, false, Some("hi"))];
185 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
186 assert!(out.is_empty());
187 }
188
189 #[test]
190 fn suppresses_when_chat_open_and_app_foreground() {
191 let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
192 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
193 let out = decide_notifications(&prev, &next, &prefs(true), true, Some("a"));
194 assert!(out.is_empty());
195 }
196
197 #[test]
198 fn fires_when_chat_open_but_app_backgrounded() {
199 let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
200 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
201 let out = decide_notifications(&prev, &next, &prefs(true), false, Some("a"));
202 assert_eq!(out.len(), 1);
203 }
204
205 #[test]
206 fn fires_when_app_foreground_but_a_different_chat_open() {
207 let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
208 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
209 let out = decide_notifications(&prev, &next, &prefs(true), true, Some("b"));
210 assert_eq!(out.len(), 1);
211 }
212
213 #[test]
214 fn suppresses_when_unread_unchanged_or_decreased() {
215 let prev = vec![chat("a", 2, Some(false), false, Some("hi"))];
216 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
217 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
218 assert!(out.is_empty());
219 }
220
221 #[test]
222 fn returns_empty_when_preference_disabled() {
223 let prev = vec![chat("a", 0, Some(false), false, Some("hi"))];
224 let next = vec![chat("a", 1, Some(false), false, Some("hi"))];
225 let out = decide_notifications(&prev, &next, &prefs(false), false, None);
226 assert!(out.is_empty());
227 }
228
229 #[test]
230 fn treats_new_chat_as_previous_unread_zero() {
231 let prev: Vec<ChatThreadSnapshot> = Vec::new();
232 let next = vec![chat("a", 1, Some(false), false, Some("hello"))];
233 let out = decide_notifications(&prev, &next, &prefs(true), false, None);
234 assert_eq!(out.len(), 1);
235 }
236
237 #[test]
238 fn active_chat_id_returns_topmost_chat_screen() {
239 let router = Router {
240 default_screen: Screen::ChatList,
241 screen_stack: vec![Screen::Chat {
242 chat_id: "x".to_string(),
243 }],
244 };
245 assert_eq!(active_chat_id(&router), Some("x".to_string()));
246 }
247
248 #[test]
249 fn active_chat_id_falls_back_to_default_screen() {
250 let router = Router {
251 default_screen: Screen::Chat {
252 chat_id: "default-x".to_string(),
253 },
254 screen_stack: vec![],
255 };
256 assert_eq!(active_chat_id(&router), Some("default-x".to_string()));
257 }
258
259 #[test]
260 fn active_chat_id_none_when_not_on_a_chat_screen() {
261 let router = Router {
262 default_screen: Screen::ChatList,
263 screen_stack: vec![Screen::Settings],
264 };
265 assert_eq!(active_chat_id(&router), None);
266 }
267}