multiverse/widgets/recovery/
default.rs

1use std::{
2    sync::{
3        Arc,
4        atomic::{AtomicBool, Ordering},
5    },
6    time::Duration,
7};
8
9use crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
10use futures_util::FutureExt as _;
11use layout::Flex;
12use matrix_sdk::{
13    Client,
14    encryption::{
15        backups::BackupState,
16        recovery::{RecoveryError, RecoveryState},
17    },
18};
19use matrix_sdk_common::executor::spawn;
20use ratatui::{
21    prelude::*,
22    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
23};
24use throbber_widgets_tui::{Throbber, ThrobberState};
25use tokio::task::JoinHandle;
26
27use super::{ShouldExit, create_centered_throbber_area};
28
29#[derive(Debug)]
30pub struct DefaultRecoveryView {
31    client: Client,
32    recovery_state: RecoveryState,
33    backup_info: BackupInfo,
34    state: ListState,
35    mode: Mode,
36}
37
38#[derive(Debug)]
39struct BackupInfo {
40    backup_state: BackupState,
41    backup_exists: Arc<AtomicBool>,
42    backup_update_task: JoinHandle<()>,
43}
44
45impl Drop for BackupInfo {
46    fn drop(&mut self) {
47        self.backup_update_task.abort();
48    }
49}
50
51#[derive(Debug, Default)]
52enum Mode {
53    #[default]
54    Default,
55    Enabling {
56        enable_task: JoinHandle<Result<String, RecoveryError>>,
57        throbber_state: ThrobberState,
58    },
59    Disabling {
60        disable_task: JoinHandle<Result<(), RecoveryError>>,
61        throbber_state: ThrobberState,
62    },
63    Done {
64        result: DoneResult,
65    },
66}
67
68#[derive(Debug)]
69enum DoneResult {
70    Enabling(Result<String, RecoveryError>),
71    Disabling(Result<(), RecoveryError>),
72}
73
74enum MenuEntries {
75    Recovery = 0,
76    KeyStorage = 1,
77}
78
79impl From<usize> for MenuEntries {
80    fn from(value: usize) -> Self {
81        match value {
82            0 => MenuEntries::Recovery,
83            1 => MenuEntries::KeyStorage,
84            _ => unreachable!("The recovery disabled view has only 2 options"),
85        }
86    }
87}
88
89impl DefaultRecoveryView {
90    pub fn new(client: Client) -> Self {
91        let mut state = ListState::default();
92        state.select_first();
93
94        let recovery_state = client.encryption().recovery().state();
95        let backup_state = client.encryption().backups().state();
96        let backup_exists = Arc::new(AtomicBool::default());
97
98        let backup_update_task = spawn({
99            let client = client.clone();
100            let backup_exists = backup_exists.clone();
101
102            async move {
103                loop {
104                    if let Ok(exists) = client.encryption().backups().fetch_exists_on_server().await
105                    {
106                        backup_exists.store(exists, Ordering::SeqCst);
107                    }
108
109                    tokio::time::sleep(Duration::from_secs(2)).await;
110                }
111            }
112        });
113
114        let backup_info = BackupInfo { backup_state, backup_exists, backup_update_task };
115
116        Self { client, state, recovery_state, backup_info, mode: Mode::default() }
117    }
118
119    fn handle_recovery_action(&mut self) {
120        let client = self.client.clone();
121
122        if matches!(self.recovery_state, RecoveryState::Disabled) {
123            let enable_task = 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 = 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}