Skip to main content

iris_chat_core/core/
routing.rs

1use super::*;
2
3/// Predicts the screen stack that [`AppCore::push_screen`] will produce, so a
4/// shell can show the destination before the core confirms it. `None` means
5/// the push has no predictable result and the shell must wait for the core.
6#[uniffi::export]
7pub fn optimistic_push_screen_stack(
8    has_account: bool,
9    mut screen_stack: Vec<Screen>,
10    screen: Screen,
11) -> Option<Vec<Screen>> {
12    if !has_account {
13        return match screen {
14            Screen::Welcome => Some(Vec::new()),
15            Screen::CreateAccount
16            | Screen::RestoreAccount
17            | Screen::RemoteSigner
18            | Screen::AddDevice => Some(vec![screen]),
19            _ => None,
20        };
21    }
22
23    match screen {
24        Screen::ChatList => Some(Vec::new()),
25        Screen::NewChat
26        | Screen::NewGroup
27        | Screen::CreateInvite
28        | Screen::JoinInvite
29        | Screen::Settings
30        | Screen::DeviceRoster => Some(vec![screen]),
31        Screen::Chat { chat_id } => {
32            let chat_id = chat_id.trim();
33            (!chat_id.is_empty()).then(|| {
34                vec![Screen::Chat {
35                    chat_id: chat_id.to_string(),
36                }]
37            })
38        }
39        Screen::DirectChatInfo { chat_id } => {
40            let chat_id = chat_id.trim();
41            if chat_id.is_empty() {
42                return None;
43            }
44            let info = Screen::DirectChatInfo {
45                chat_id: chat_id.to_string(),
46            };
47            if screen_stack.last() != Some(&info) {
48                screen_stack.push(info);
49            }
50            Some(screen_stack)
51        }
52        Screen::GroupDetails { group_id } => {
53            let group_id = group_id.trim();
54            if group_id.is_empty() {
55                return None;
56            }
57            let group_chat_id = group_chat_id(group_id);
58            if active_chat_in_stack(&screen_stack) != Some(group_chat_id.as_str()) {
59                screen_stack = vec![Screen::Chat {
60                    chat_id: group_chat_id,
61                }];
62            }
63            let details = Screen::GroupDetails {
64                group_id: group_id.to_string(),
65            };
66            if screen_stack.last() != Some(&details) {
67                screen_stack.push(details);
68            }
69            Some(screen_stack)
70        }
71        Screen::CreateAccount
72        | Screen::RestoreAccount
73        | Screen::RemoteSigner
74        | Screen::AddDevice
75        | Screen::DeviceRevoked
76        | Screen::Welcome => None,
77    }
78}
79
80/// Whether a shell should drop its optimistic stack and show the core's:
81/// the account is gone, the core caught up, or the shell's timeout `expired`.
82#[uniffi::export]
83pub fn pending_navigation_settled(
84    has_account: bool,
85    pending_stack: Vec<Screen>,
86    core_stack: Vec<Screen>,
87    expired: bool,
88) -> bool {
89    !has_account || expired || pending_stack == core_stack
90}
91
92fn active_chat_in_stack(screen_stack: &[Screen]) -> Option<&str> {
93    screen_stack.iter().rev().find_map(|screen| match screen {
94        Screen::Chat { chat_id } => Some(chat_id.as_str()),
95        _ => None,
96    })
97}
98
99impl AppCore {
100    pub(super) fn push_screen(&mut self, screen: Screen) {
101        if self.state.account.is_none() {
102            if !matches!(screen, Screen::RemoteSigner) {
103                self.cancel_remote_signer_login();
104            }
105            match screen {
106                Screen::RemoteSigner => {
107                    self.start_remote_signer_login(None);
108                    return;
109                }
110                Screen::Welcome => {
111                    self.stop_pending_linked_device();
112                    self.screen_stack.clear();
113                    self.active_chat_id = None;
114                }
115                Screen::CreateAccount | Screen::RestoreAccount | Screen::AddDevice => {
116                    self.screen_stack = vec![screen];
117                    self.active_chat_id = None;
118                    if matches!(self.screen_stack.last(), Some(Screen::AddDevice))
119                        && self.pending_linked_device.is_none()
120                    {
121                        self.start_linked_device("");
122                        return;
123                    }
124                }
125                _ => return,
126            }
127
128            self.rebuild_persist_and_emit_state();
129            return;
130        }
131
132        match screen {
133            Screen::CreateAccount
134            | Screen::RestoreAccount
135            | Screen::AddDevice
136            | Screen::RemoteSigner => return,
137            Screen::ChatList => {
138                self.screen_stack.clear();
139                self.active_chat_id = None;
140            }
141            Screen::NewChat => {
142                if !self.can_use_chats() {
143                    self.state.toast =
144                        Some(chat_unavailable_message(self.logged_in.as_ref()).to_string());
145                    self.emit_state();
146                    return;
147                }
148                self.screen_stack = vec![Screen::NewChat];
149                self.active_chat_id = None;
150            }
151            Screen::NewGroup => {
152                if !self.can_use_chats() {
153                    self.state.toast =
154                        Some(chat_unavailable_message(self.logged_in.as_ref()).to_string());
155                    self.emit_state();
156                    return;
157                }
158                self.screen_stack = vec![Screen::NewGroup];
159                self.active_chat_id = None;
160            }
161            Screen::CreateInvite => {
162                if !self.can_use_chats() {
163                    self.state.toast =
164                        Some(chat_unavailable_message(self.logged_in.as_ref()).to_string());
165                    self.emit_state();
166                    return;
167                }
168                self.screen_stack = vec![Screen::CreateInvite];
169                self.active_chat_id = None;
170                self.create_public_invite();
171                return;
172            }
173            Screen::JoinInvite => {
174                if !self.can_use_chats() {
175                    self.state.toast =
176                        Some(chat_unavailable_message(self.logged_in.as_ref()).to_string());
177                    self.emit_state();
178                    return;
179                }
180                self.screen_stack = vec![Screen::JoinInvite];
181                self.active_chat_id = None;
182            }
183            Screen::Settings => {
184                self.screen_stack = vec![Screen::Settings];
185                self.active_chat_id = None;
186            }
187            Screen::Chat { chat_id } => {
188                self.open_chat(&chat_id);
189                return;
190            }
191            Screen::DirectChatInfo { chat_id } => {
192                let Some(chat_id) = self.normalize_chat_id(&chat_id) else {
193                    return;
194                };
195                if !matches!(chat_kind_for_id(&chat_id), ChatKind::Direct) {
196                    return;
197                }
198                self.ensure_thread_record(&chat_id, unix_now().get());
199                if !matches!(
200                    self.screen_stack.last(),
201                    Some(Screen::DirectChatInfo { chat_id: current }) if current == &chat_id
202                ) {
203                    self.screen_stack.push(Screen::DirectChatInfo { chat_id });
204                }
205            }
206            Screen::GroupDetails { group_id } => {
207                let Some(group_id) = normalize_group_id(&group_id) else {
208                    return;
209                };
210                let group_chat_id = group_chat_id(&group_id);
211                if self.active_chat_id.as_deref() != Some(group_chat_id.as_str()) {
212                    self.open_chat(&group_chat_id);
213                }
214                if !matches!(
215                    self.screen_stack.last(),
216                    Some(Screen::GroupDetails { group_id: current }) if current == &group_id
217                ) {
218                    self.screen_stack.push(Screen::GroupDetails { group_id });
219                }
220            }
221            Screen::DeviceRoster => {
222                self.screen_stack = vec![Screen::DeviceRoster];
223                self.active_chat_id = None;
224                self.fetch_pending_device_invites_for_local_owner();
225            }
226            Screen::DeviceRevoked | Screen::Welcome => return,
227        }
228
229        self.rebuild_persist_and_emit_state();
230    }
231
232    pub(super) fn navigate_back(&mut self) {
233        self.reset_pending_invite_acceptance();
234        if matches!(self.screen_stack.last(), Some(Screen::RemoteSigner)) {
235            self.cancel_remote_signer_login();
236        }
237        if self.screen_stack.pop().is_none() {
238            return;
239        }
240        self.sync_active_chat_from_router();
241        self.rebuild_persist_and_emit_state();
242    }
243
244    pub(super) fn update_screen_stack(&mut self, stack: Vec<Screen>) {
245        if self.state.account.is_none() {
246            self.screen_stack = stack
247                .into_iter()
248                .filter(|screen| {
249                    matches!(
250                        screen,
251                        Screen::CreateAccount
252                            | Screen::RestoreAccount
253                            | Screen::AddDevice
254                            | Screen::RemoteSigner
255                    )
256                })
257                .collect();
258            if !self
259                .screen_stack
260                .iter()
261                .any(|screen| matches!(screen, Screen::AddDevice))
262            {
263                self.stop_pending_linked_device();
264            }
265            if !self
266                .screen_stack
267                .iter()
268                .any(|screen| matches!(screen, Screen::RemoteSigner))
269            {
270                self.cancel_remote_signer_login();
271            }
272            self.active_chat_id = None;
273            self.rebuild_persist_and_emit_state();
274            return;
275        }
276
277        let mut normalized_stack = Vec::new();
278        for screen in stack {
279            match screen {
280                Screen::Welcome
281                | Screen::CreateAccount
282                | Screen::RestoreAccount
283                | Screen::RemoteSigner
284                | Screen::AddDevice
285                | Screen::ChatList
286                | Screen::DeviceRevoked => {}
287                Screen::Settings => normalized_stack.push(Screen::Settings),
288                Screen::CreateInvite => {
289                    if self.can_use_chats() {
290                        normalized_stack.push(Screen::CreateInvite);
291                    }
292                }
293                Screen::JoinInvite => {
294                    if self.can_use_chats() {
295                        normalized_stack.push(Screen::JoinInvite);
296                    }
297                }
298                Screen::NewChat => {
299                    if self.can_use_chats() {
300                        normalized_stack.push(Screen::NewChat);
301                    }
302                }
303                Screen::NewGroup => {
304                    if self.can_use_chats() {
305                        normalized_stack.push(Screen::NewGroup);
306                    }
307                }
308                Screen::DeviceRoster => normalized_stack.push(Screen::DeviceRoster),
309                Screen::Chat { chat_id } => {
310                    if self.can_use_chats() {
311                        if let Some(chat_id) = self.normalize_chat_id(&chat_id) {
312                            normalized_stack.push(Screen::Chat { chat_id });
313                        }
314                    }
315                }
316                Screen::DirectChatInfo { chat_id } => {
317                    if self.can_use_chats() {
318                        if let Some(chat_id) = self.normalize_chat_id(&chat_id) {
319                            if matches!(chat_kind_for_id(&chat_id), ChatKind::Direct) {
320                                self.ensure_thread_record(&chat_id, unix_now().get());
321                                normalized_stack.push(Screen::DirectChatInfo { chat_id });
322                            }
323                        }
324                    }
325                }
326                Screen::GroupDetails { group_id } => {
327                    if self.can_use_chats() {
328                        if let Some(group_id) = normalize_group_id(&group_id) {
329                            normalized_stack.push(Screen::GroupDetails { group_id });
330                        }
331                    }
332                }
333            }
334        }
335
336        self.screen_stack = normalized_stack;
337        self.sync_active_chat_from_router();
338        self.rebuild_persist_and_emit_state();
339    }
340
341    pub(super) fn is_device_roster_open(&self) -> bool {
342        matches!(self.screen_stack.last(), Some(Screen::DeviceRoster))
343    }
344
345    pub(super) fn is_chat_visible(&self, chat_id: &str) -> bool {
346        self.app_in_foreground
347            && matches!(
348                self.screen_stack.last(),
349                Some(Screen::Chat { chat_id: current }) if current == chat_id
350            )
351    }
352
353    pub(super) fn sync_active_chat_from_router(&mut self) {
354        match active_chat_in_stack(&self.screen_stack).map(str::to_string) {
355            Some(chat_id) => {
356                self.active_chat_id = Some(chat_id.clone());
357                if let Some(thread) = self.threads.get_mut(&chat_id) {
358                    thread.unread_count = 0;
359                }
360            }
361            _ => {
362                self.active_chat_id = None;
363            }
364        }
365    }
366}
367
368#[cfg(test)]
369mod optimistic_navigation_tests {
370    use super::*;
371
372    fn chat(chat_id: &str) -> Screen {
373        Screen::Chat {
374            chat_id: chat_id.to_string(),
375        }
376    }
377
378    fn group_details(group_id: &str) -> Screen {
379        Screen::GroupDetails {
380            group_id: group_id.to_string(),
381        }
382    }
383
384    fn direct_info(chat_id: &str) -> Screen {
385        Screen::DirectChatInfo {
386            chat_id: chat_id.to_string(),
387        }
388    }
389
390    #[test]
391    fn logged_out_pushes_only_predict_onboarding_screens() {
392        let push = |screen| optimistic_push_screen_stack(false, vec![Screen::AddDevice], screen);
393        assert_eq!(push(Screen::Welcome), Some(vec![]));
394        assert_eq!(
395            push(Screen::RestoreAccount),
396            Some(vec![Screen::RestoreAccount])
397        );
398        assert_eq!(push(Screen::RemoteSigner), Some(vec![Screen::RemoteSigner]));
399        assert_eq!(push(Screen::Settings), None);
400        assert_eq!(push(chat("a")), None);
401    }
402
403    #[test]
404    fn logged_in_root_screens_replace_the_stack() {
405        let push = |screen| optimistic_push_screen_stack(true, vec![chat("a")], screen);
406        assert_eq!(push(Screen::ChatList), Some(vec![]));
407        assert_eq!(push(Screen::Settings), Some(vec![Screen::Settings]));
408        assert_eq!(push(chat(" b ")), Some(vec![chat("b")]));
409        assert_eq!(push(chat("  ")), None);
410        assert_eq!(push(Screen::Welcome), None);
411        assert_eq!(push(Screen::AddDevice), None);
412        assert_eq!(push(Screen::DeviceRevoked), None);
413    }
414
415    #[test]
416    fn direct_chat_info_is_pushed_once() {
417        let stack = vec![chat("a")];
418        let pushed = optimistic_push_screen_stack(true, stack, direct_info(" a ")).unwrap();
419        assert_eq!(pushed, vec![chat("a"), direct_info("a")]);
420        assert_eq!(
421            optimistic_push_screen_stack(true, pushed.clone(), direct_info("a")),
422            Some(pushed)
423        );
424        assert_eq!(
425            optimistic_push_screen_stack(true, vec![], direct_info(" ")),
426            None
427        );
428    }
429
430    #[test]
431    fn group_details_opens_the_group_chat_unless_it_is_active() {
432        let opened = vec![chat("group:g"), group_details("g")];
433        assert_eq!(
434            optimistic_push_screen_stack(true, vec![chat("a")], group_details(" g ")),
435            Some(opened.clone())
436        );
437        assert_eq!(
438            optimistic_push_screen_stack(true, vec![chat("group:g")], group_details("g")),
439            Some(opened.clone())
440        );
441        assert_eq!(
442            optimistic_push_screen_stack(true, opened.clone(), group_details("g")),
443            Some(opened)
444        );
445        assert_eq!(
446            optimistic_push_screen_stack(true, vec![], group_details("")),
447            None
448        );
449    }
450
451    #[test]
452    fn pending_navigation_settles_on_logout_match_or_expiry() {
453        let pending = || vec![chat("a")];
454        assert!(!pending_navigation_settled(true, pending(), vec![], false));
455        assert!(pending_navigation_settled(
456            true,
457            pending(),
458            pending(),
459            false
460        ));
461        assert!(pending_navigation_settled(true, pending(), vec![], true));
462        assert!(pending_navigation_settled(false, pending(), vec![], false));
463    }
464}