multiverse/widgets/recovery/
default.rs

1use std::{
2    sync::{
3        atomic::{AtomicBool, Ordering},
4        Arc,
5    },
6    time::Duration,
7};
8
9use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
10use futures_util::FutureExt as _;
11use layout::Flex;
12use matrix_sdk::{
13    encryption::{
14        backups::BackupState,
15        recovery::{RecoveryError, RecoveryState},
16    },
17    Client,
18};
19use ratatui::{
20    prelude::*,
21    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
22};
23use throbber_widgets_tui::{Throbber, ThrobberState};
24use tokio::task::JoinHandle;
25
26use super::{create_centered_throbber_area, ShouldExit};
27
28#[derive(Debug)]
29pub struct DefaultRecoveryView {
30    client: Client,
31    recovery_state: RecoveryState,
32    backup_info: BackupInfo,
33    state: ListState,
34    mode: Mode,
35}
36
37#[derive(Debug)]
38struct BackupInfo {
39    backup_state: BackupState,
40    backup_exists: Arc<AtomicBool>,
41    backup_update_task: JoinHandle<()>,
42}
43
44impl Drop for BackupInfo {
45    fn drop(&mut self) {
46        self.backup_update_task.abort();
47    }
48}
49
50#[derive(Debug, Default)]
51enum Mode {
52    #[default]
53    Default,
54    Enabling {
55        enable_task: JoinHandle<Result<String, RecoveryError>>,
56        throbber_state: ThrobberState,
57    },
58    Disabling {
59        disable_task: JoinHandle<Result<(), RecoveryError>>,
60        throbber_state: ThrobberState,
61    },
62    Done {
63        result: DoneResult,
64    },
65}
66
67#[derive(Debug)]
68enum DoneResult {
69    Enabling(Result<String, RecoveryError>),
70    Disabling(Result<(), RecoveryError>),
71}
72
73enum MenuEntries {
74    Recovery = 0,
75    KeyStorage = 1,
76}
77
78impl From<usize> for MenuEntries {
79    fn from(value: usize) -> Self {
80        match value {
81            0 => MenuEntries::Recovery,
82            1 => MenuEntries::KeyStorage,
83            _ => unreachable!("The recovery disabled view has only 2 options"),
84        }
85    }
86}
87
88impl DefaultRecoveryView {
89    pub fn new(client: Client) -> Self {
90        let mut state = ListState::default();
91        state.select_first();
92
93        let recovery_state = client.encryption().recovery().state();
94        let backup_state = client.encryption().backups().state();
95        let backup_exists = Arc::new(AtomicBool::default());
96
97        let backup_update_task = tokio::spawn({
98            let client = client.clone();
99            let backup_exists = backup_exists.clone();
100
101            async move {
102                loop {
103                    if let Ok(exists) = client.encryption().backups().fetch_exists_on_server().await
104                    {
105                        backup_exists.store(exists, Ordering::SeqCst);
106                    }
107
108                    tokio::time::sleep(Duration::from_secs(2)).await;
109                }
110            }
111        });
112
113        let backup_info = BackupInfo { backup_state, backup_exists, backup_update_task };
114
115        Self { client, state, recovery_state, backup_info, mode: Mode::default() }
116    }
117
118    fn handle_recovery_action(&mut self) {
119        let client = self.client.clone();
120
121        if matches!(self.recovery_state, RecoveryState::Disabled) {
122            let enable_task =
123                tokio::spawn(async move { client.encryption().recovery().enable().await });
124
125            self.mode = Mode::Enabling { enable_task, throbber_state: ThrobberState::default() };
126        } else {
127            let disable_task = tokio::spawn(async move {
128                // TODO: Handle errors here?
129                let _ = client.encryption().recovery().disable().await;
130                Ok(())
131            });
132
133            self.mode = Mode::Disabling { disable_task, throbber_state: ThrobberState::default() };
134        }
135    }
136
137    async fn handle_backup_action(&mut self) {
138        let backup_state = self.backup_info.backup_state;
139        let backup_exists = self.backup_info.backup_exists.load(Ordering::SeqCst);
140
141        match (backup_state, backup_exists) {
142            (BackupState::Unknown, false) => {
143                let _ = self.client.encryption().backups().create().await;
144            }
145            (BackupState::Unknown, true) | (BackupState::Enabled, _) => {
146                let _ = self.client.encryption().backups().disable_and_delete().await;
147                self.backup_info.backup_exists.store(false, Ordering::SeqCst);
148            }
149            (BackupState::Creating, _)
150            | (BackupState::Enabling, _)
151            | (BackupState::Resuming, _)
152            | (BackupState::Downloading, _)
153            | (BackupState::Disabling, _) => {}
154        }
155    }
156
157    pub async fn handle_key(&mut self, key: KeyEvent) -> ShouldExit {
158        use ShouldExit::*;
159
160        if key.kind != KeyEventKind::Press {
161            return No;
162        }
163
164        match self.mode {
165            Mode::Default => match key.code {
166                KeyCode::Esc | KeyCode::Char('q') => Yes,
167                KeyCode::Char('j') | KeyCode::Down => {
168                    self.state.select_next();
169                    No
170                }
171                KeyCode::Char('k') | KeyCode::Up => {
172                    self.state.select_previous();
173                    No
174                }
175                KeyCode::Enter | KeyCode::Char(' ') => {
176                    if let Some(selected) = self.state.selected() {
177                        match selected.into() {
178                            MenuEntries::Recovery => self.handle_recovery_action(),
179                            MenuEntries::KeyStorage => self.handle_backup_action().await,
180                        }
181                    }
182
183                    No
184                }
185                _ => No,
186            },
187            Mode::Enabling { .. } | Mode::Disabling { .. } => No,
188            Mode::Done { .. } => {
189                self.mode = Mode::Default;
190                OnlySubScreen
191            }
192        }
193    }
194
195    pub fn on_tick(&mut self) {
196        use Mode::*;
197
198        match &mut self.mode {
199            Enabling { throbber_state, .. } | Disabling { throbber_state, .. } => {
200                throbber_state.calc_next()
201            }
202            Default | Done { .. } => {}
203        }
204    }
205
206    pub fn is_idle(&self) -> bool {
207        match self.mode {
208            Mode::Default => true,
209            Mode::Enabling { .. } | Mode::Disabling { .. } | Mode::Done { .. } => false,
210        }
211    }
212
213    fn update_state(&mut self) {
214        use Mode::*;
215
216        let recovery_state = self.client.encryption().recovery().state();
217        let backup_state = self.client.encryption().backups().state();
218
219        self.recovery_state = recovery_state;
220        self.backup_info.backup_state = backup_state;
221
222        match &mut self.mode {
223            Default => {}
224            // Check if the task enabling recovery is done, if so, let's go into the `Done` mode.
225            Enabling { enable_task, .. } => {
226                if enable_task.is_finished() {
227                    let result = enable_task
228                        .now_or_never()
229                        .expect("The task should have finished, we checked it")
230                        .expect("The recovery enabling task should neve panic");
231                    self.mode = Done { result: DoneResult::Enabling(result) };
232                }
233            }
234            Disabling { disable_task, .. } => {
235                if disable_task.is_finished() {
236                    let result = disable_task
237                        .now_or_never()
238                        .expect("The task should have finished, we checked it")
239                        .expect("The recovery enabling task should neve panic");
240                    self.mode = Done { result: DoneResult::Disabling(result) };
241                }
242            }
243
244            // Done only transitions into another state if the user presses a button.
245            Done { .. } => {}
246        }
247    }
248}
249
250impl Widget for &mut DefaultRecoveryView {
251    fn render(self, area: Rect, buf: &mut Buffer)
252    where
253        Self: Sized,
254    {
255        self.update_state();
256
257        let style = match &self.mode {
258            Mode::Default => Style::default(),
259            Mode::Enabling { .. } | Mode::Done { .. } | Mode::Disabling { .. } => {
260                Style::default().dim()
261            }
262        };
263
264        let recovery_item = match self.recovery_state {
265            RecoveryState::Unknown => {
266                ListItem::new("Recovery    [?]").style(Style::default().dim())
267            }
268            RecoveryState::Enabled => ListItem::new("Recovery    [x]").style(style),
269            RecoveryState::Disabled | RecoveryState::Incomplete => {
270                ListItem::new("Recovery    [ ]").style(style)
271            }
272        };
273
274        let backup_state = self.backup_info.backup_state;
275        let backup_exists = self.backup_info.backup_exists.load(Ordering::SeqCst);
276
277        let backups = match (backup_state, backup_exists) {
278            (BackupState::Unknown, true) => {
279                ListItem::new("Key storage [~] (a backup exists but we don't have access to it)")
280                    .dim()
281            }
282            (BackupState::Unknown, false) => ListItem::new("Key storage [ ]"),
283            (BackupState::Creating, _)
284            | (BackupState::Enabling, _)
285            | (BackupState::Resuming, _) => ListItem::new("Key storage [x]").dim(),
286            (BackupState::Enabled, true) => ListItem::new("Key storage [x]"),
287            (BackupState::Enabled, false) => ListItem::new("Key storage [x]"),
288            (BackupState::Downloading, _) | (BackupState::Disabling, _) => {
289                ListItem::new("Key storage [ ]").dim()
290            }
291        };
292
293        let list = List::new(vec![recovery_item, backups])
294            .highlight_symbol("> ")
295            .highlight_spacing(ratatui::widgets::HighlightSpacing::Always);
296
297        StatefulWidget::render(list, area, buf, &mut self.state);
298
299        match &mut self.mode {
300            Mode::Default => {}
301            Mode::Enabling { throbber_state, .. } => {
302                let throbber = Throbber::default()
303                    .label("Enabling recovery")
304                    .throbber_set(throbber_widgets_tui::BRAILLE_EIGHT_DOUBLE);
305                let centered_area = create_centered_throbber_area(area);
306                StatefulWidget::render(throbber, centered_area, buf, throbber_state);
307            }
308            Mode::Disabling { throbber_state, .. } => {
309                let throbber = Throbber::default()
310                    .label("Disabling recovery")
311                    .throbber_set(throbber_widgets_tui::BRAILLE_EIGHT_DOUBLE);
312                let centered_area = create_centered_throbber_area(area);
313                StatefulWidget::render(throbber, centered_area, buf, throbber_state);
314            }
315
316            Mode::Done { result } => {
317                let vertical = Layout::vertical([
318                    Constraint::Fill(1),
319                    Constraint::Length(4),
320                    Constraint::Fill(1),
321                ])
322                .flex(Flex::Center);
323                let horizontal = Layout::horizontal([Constraint::Length(70)]).flex(Flex::Center);
324                let [_, area, _] = vertical.areas(area);
325                let [popup] = horizontal.areas(area);
326
327                Clear.render(popup, buf);
328
329                let block = Block::new().borders(Borders::all());
330
331                let text = match result {
332                    DoneResult::Enabling(Ok(recovery_key)) => {
333                        format!("Recovery has been enabled:\n{recovery_key}")
334                    }
335                    DoneResult::Enabling(Err(error)) => {
336                        format!("Failed to enable recovery: {error:?}")
337                    }
338                    DoneResult::Disabling(Ok(())) => "Recovery has been disabled".to_owned(),
339                    DoneResult::Disabling(Err(error)) => {
340                        format!("Failed to disable recovery: {error:?}")
341                    }
342                };
343
344                Paragraph::new(text).centered().block(block).render(popup, buf);
345            }
346        }
347    }
348}