multiverse/
main.rs

1#![allow(clippy::large_enum_variant)]
2
3use std::{
4    collections::HashMap,
5    io::{self, stdout, Write},
6    path::{Path, PathBuf},
7    sync::Arc,
8    time::{Duration, Instant},
9};
10
11use clap::Parser;
12use color_eyre::Result;
13use crossterm::{
14    event::{
15        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
16    },
17    execute,
18};
19use futures_util::{pin_mut, StreamExt as _};
20use imbl::Vector;
21use layout::Flex;
22use matrix_sdk::{
23    authentication::matrix::MatrixSession,
24    config::StoreConfig,
25    encryption::{BackupDownloadStrategy, EncryptionSettings},
26    reqwest::Url,
27    ruma::OwnedRoomId,
28    AuthSession, Client, SqliteCryptoStore, SqliteEventCacheStore, SqliteStateStore,
29};
30use matrix_sdk_common::locks::Mutex;
31use matrix_sdk_ui::{
32    room_list_service::{self, filters::new_filter_non_left},
33    sync_service::SyncService,
34    timeline::TimelineItem,
35    Timeline as SdkTimeline,
36};
37use ratatui::{prelude::*, style::palette::tailwind, widgets::*};
38use throbber_widgets_tui::{Throbber, ThrobberState};
39use tokio::{spawn, task::JoinHandle};
40use tracing::{error, warn};
41use tracing_subscriber::EnvFilter;
42use widgets::{
43    recovery::create_centered_throbber_area, room_view::RoomView, settings::SettingsView,
44};
45
46use crate::widgets::{
47    help::HelpView,
48    room_list::{ExtraRoomInfo, RoomInfos, RoomList, Rooms},
49    status::Status,
50};
51
52mod widgets;
53
54const HEADER_BG: Color = tailwind::BLUE.c950;
55const NORMAL_ROW_COLOR: Color = tailwind::SLATE.c950;
56const ALT_ROW_COLOR: Color = tailwind::SLATE.c900;
57const SELECTED_STYLE_FG: Color = tailwind::BLUE.c300;
58const TEXT_COLOR: Color = tailwind::SLATE.c200;
59
60type UiRooms = Arc<Mutex<HashMap<OwnedRoomId, room_list_service::Room>>>;
61type Timelines = Arc<Mutex<HashMap<OwnedRoomId, Timeline>>>;
62
63#[derive(Debug, Parser)]
64struct Cli {
65    /// The homeserver the client should connect to.
66    server_name: String,
67
68    /// The path where session specific data should be stored.
69    #[clap(default_value = "/tmp/")]
70    session_path: PathBuf,
71
72    /// Set the proxy that should be used for the connection.
73    #[clap(short, long, env = "PROXY")]
74    proxy: Option<Url>,
75}
76
77#[derive(Default)]
78pub enum GlobalMode {
79    /// The default mode, no popout screen is opened.
80    #[default]
81    Default,
82    /// Mode where we have opened the help screen.
83    Help,
84    /// Mode where we have opened the settings screen.
85    Settings { view: SettingsView },
86    /// Mode where we are shutting our tasks down and exiting multiverse.
87    Exiting { shutdown_task: JoinHandle<()> },
88}
89
90/// Helper function to create a centered rect using up certain percentage of the
91/// available rect `r`
92fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
93    let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center);
94    let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center);
95    let [area] = vertical.areas(area);
96    let [area] = horizontal.areas(area);
97    area
98}
99
100#[tokio::main]
101async fn main() -> Result<()> {
102    let file_writer = tracing_appender::rolling::hourly("/tmp/", "logs-");
103
104    tracing_subscriber::fmt()
105        .with_env_filter(EnvFilter::from_default_env())
106        .with_ansi(false)
107        .with_writer(file_writer)
108        .init();
109
110    color_eyre::install()?;
111
112    let cli = Cli::parse();
113    let client = configure_client(cli).await?;
114
115    let event_cache = client.event_cache();
116    event_cache.subscribe()?;
117
118    let terminal = ratatui::init();
119    execute!(stdout(), EnableMouseCapture)?;
120    let mut app = App::new(client).await?;
121
122    app.run(terminal).await
123}
124
125pub struct Timeline {
126    timeline: Arc<SdkTimeline>,
127    items: Arc<Mutex<Vector<Arc<TimelineItem>>>>,
128    task: JoinHandle<()>,
129}
130
131#[derive(Default)]
132pub struct AppState {
133    /// What popup are we showing that is covering the majority of the screen,
134    /// mainly used for help and settings screens.
135    global_mode: GlobalMode,
136
137    /// State for a global throbber.
138    throbber_state: ThrobberState,
139}
140
141struct App {
142    /// Reference to the main SDK client.
143    client: Client,
144
145    /// The sync service used for synchronizing events.
146    sync_service: Arc<SyncService>,
147
148    /// Timelines data structures for each room.
149    timelines: Timelines,
150
151    /// The room list widget on the left-hand side of the screen.
152    room_list: RoomList,
153
154    /// A view displaying the contents of the selected room, the widget on the
155    /// right-hand side of the screen.
156    room_view: RoomView,
157
158    /// Task listening to room list service changes, and spawning timelines.
159    listen_task: JoinHandle<()>,
160
161    /// The status widet at the bottom of the screen.
162    status: Status,
163
164    state: AppState,
165
166    last_tick: Instant,
167}
168
169impl App {
170    const TICK_RATE: Duration = Duration::from_millis(250);
171
172    async fn new(client: Client) -> Result<Self> {
173        let sync_service = Arc::new(SyncService::builder(client.clone()).build().await?);
174
175        let rooms = Rooms::default();
176        let room_infos = RoomInfos::default();
177        let ui_rooms = UiRooms::default();
178        let timelines = Timelines::default();
179
180        let room_list_service = sync_service.room_list_service();
181        let all_rooms = room_list_service.all_rooms().await?;
182
183        let listen_task = spawn(Self::listen_task(
184            rooms.clone(),
185            room_infos.clone(),
186            ui_rooms.clone(),
187            timelines.clone(),
188            all_rooms,
189        ));
190
191        // This will sync (with encryption) until an error happens or the program is
192        // stopped.
193        sync_service.start().await;
194
195        let status = Status::new();
196        let room_list = RoomList::new(
197            rooms,
198            ui_rooms.clone(),
199            room_infos,
200            sync_service.clone(),
201            status.handle(),
202        );
203
204        let room_view = RoomView::new(ui_rooms, timelines.clone(), status.handle());
205
206        Ok(Self {
207            sync_service,
208            timelines,
209            room_list,
210            room_view,
211            client,
212            listen_task,
213            status,
214            state: AppState::default(),
215            last_tick: Instant::now(),
216        })
217    }
218
219    async fn listen_task(
220        rooms: Rooms,
221        room_infos: RoomInfos,
222        ui_rooms: UiRooms,
223        timelines: Timelines,
224        all_rooms: room_list_service::RoomList,
225    ) {
226        let (stream, entries_controller) = all_rooms.entries_with_dynamic_adapters(50_000);
227        entries_controller.set_filter(Box::new(new_filter_non_left()));
228
229        pin_mut!(stream);
230
231        while let Some(diffs) = stream.next().await {
232            let all_rooms = {
233                // Apply the diffs to the list of room entries.
234                let mut rooms = rooms.lock();
235
236                for diff in diffs {
237                    diff.apply(&mut rooms);
238                }
239
240                // Collect rooms early to release the room entries list lock.
241                (*rooms).clone()
242            };
243
244            // Clone the previous set of ui rooms to avoid keeping the ui_rooms lock (which
245            // we couldn't do below, because it's a sync lock, and has to be
246            // sync b/o rendering; and we'd have to cross await points
247            // below).
248            let previous_ui_rooms = ui_rooms.lock().clone();
249
250            let mut new_ui_rooms = HashMap::new();
251            let mut new_timelines = Vec::new();
252
253            // Update all the room info for all rooms.
254            for room in all_rooms.iter() {
255                let raw_name = room.name();
256                let display_name = room.cached_display_name();
257                let is_dm = room
258                    .is_direct()
259                    .await
260                    .map_err(|err| {
261                        warn!("couldn't figure whether a room is a DM or not: {err}");
262                    })
263                    .ok();
264                room_infos.lock().insert(
265                    room.room_id().to_owned(),
266                    ExtraRoomInfo { raw_name, display_name, is_dm },
267                );
268            }
269
270            // Initialize all the new rooms.
271            for ui_room in
272                all_rooms.into_iter().filter(|room| !previous_ui_rooms.contains_key(room.room_id()))
273            {
274                // Initialize the timeline.
275                let builder = match ui_room.default_room_timeline_builder() {
276                    Ok(builder) => builder,
277                    Err(err) => {
278                        error!("error when getting default timeline builder: {err}");
279                        continue;
280                    }
281                };
282
283                let timeline = match builder.build().await {
284                    Ok(timeline) => timeline,
285                    Err(err) => {
286                        error!("error when creating default timeline: {err}");
287                        continue;
288                    }
289                };
290
291                // Save the timeline in the cache.
292                let (items, stream) = timeline.subscribe().await;
293                let items = Arc::new(Mutex::new(items));
294
295                // Spawn a timeline task that will listen to all the timeline item changes.
296                let i = items.clone();
297                let timeline_task = spawn(async move {
298                    pin_mut!(stream);
299                    let items = i;
300                    while let Some(diffs) = stream.next().await {
301                        let mut items = items.lock();
302
303                        for diff in diffs {
304                            diff.apply(&mut items);
305                        }
306                    }
307                });
308
309                new_timelines.push((
310                    ui_room.room_id().to_owned(),
311                    Timeline { timeline: Arc::new(timeline), items, task: timeline_task },
312                ));
313
314                // Save the room list service room in the cache.
315                new_ui_rooms.insert(ui_room.room_id().to_owned(), ui_room);
316            }
317
318            ui_rooms.lock().extend(new_ui_rooms);
319            timelines.lock().extend(new_timelines);
320        }
321    }
322
323    fn set_global_mode(&mut self, mode: GlobalMode) {
324        self.state.global_mode = mode;
325    }
326
327    async fn handle_global_event(&mut self, event: Event) -> Result<bool> {
328        use KeyCode::*;
329
330        match event {
331            Event::Key(KeyEvent { code: F(1), modifiers: KeyModifiers::NONE, .. }) => {
332                self.set_global_mode(GlobalMode::Help)
333            }
334
335            Event::Key(KeyEvent { code: F(10), modifiers: KeyModifiers::NONE, .. }) => self
336                .set_global_mode(GlobalMode::Settings {
337                    view: SettingsView::new(self.client.clone(), self.sync_service.clone()),
338                }),
339
340            Event::Key(KeyEvent {
341                code: Char('j') | Down,
342                modifiers: KeyModifiers::CONTROL,
343                ..
344            }) => {
345                self.room_list.next_room();
346                let room_id = self.room_list.get_selected_room_id();
347                self.room_view.set_selected_room(room_id);
348            }
349
350            Event::Key(KeyEvent {
351                code: Char('k') | Up, modifiers: KeyModifiers::CONTROL, ..
352            }) => {
353                self.room_list.previous_room();
354                let room_id = self.room_list.get_selected_room_id();
355                self.room_view.set_selected_room(room_id);
356            }
357
358            Event::Key(KeyEvent { code: Char('m'), modifiers: KeyModifiers::ALT, .. }) => {
359                self.room_view.mark_as_read().await
360            }
361
362            Event::Key(KeyEvent { code: Char('q'), modifiers: KeyModifiers::CONTROL, .. }) => {
363                if !matches!(self.state.global_mode, GlobalMode::Default) {
364                    self.set_global_mode(GlobalMode::Default);
365                } else {
366                    return Ok(true);
367                }
368            }
369
370            _ => self.room_view.handle_event(event).await,
371        }
372
373        Ok(false)
374    }
375
376    fn on_tick(&mut self) {
377        self.state.throbber_state.calc_next();
378
379        match &mut self.state.global_mode {
380            GlobalMode::Help | GlobalMode::Default | GlobalMode::Exiting { .. } => {}
381            GlobalMode::Settings { view } => {
382                view.on_tick();
383            }
384        }
385    }
386
387    async fn render_loop(&mut self, mut terminal: Terminal<impl Backend>) -> Result<()> {
388        use KeyCode::*;
389
390        loop {
391            terminal.draw(|f| f.render_widget(&mut *self, f.area()))?;
392
393            if event::poll(Duration::from_millis(100))? {
394                let event = event::read()?;
395
396                match &mut self.state.global_mode {
397                    GlobalMode::Default => {
398                        if self.handle_global_event(event).await? {
399                            let sync_service = self.sync_service.clone();
400                            let timelines = self.timelines.clone();
401                            let listen_task = self.listen_task.abort_handle();
402
403                            let shutdown_task = spawn(async move {
404                                sync_service.stop().await;
405
406                                listen_task.abort();
407
408                                for timeline in timelines.lock().values() {
409                                    timeline.task.abort();
410                                }
411                            });
412
413                            self.set_global_mode(GlobalMode::Exiting { shutdown_task });
414                        }
415                    }
416                    GlobalMode::Help => {
417                        if let Event::Key(key) = event {
418                            if let (KeyModifiers::NONE, Char('q') | Esc) = (key.modifiers, key.code)
419                            {
420                                self.set_global_mode(GlobalMode::Default)
421                            }
422                        }
423                    }
424                    GlobalMode::Settings { view } => {
425                        if let Event::Key(key) = event {
426                            if view.handle_key_press(key).await {
427                                self.set_global_mode(GlobalMode::Default);
428                            }
429                        }
430                    }
431                    GlobalMode::Exiting { .. } => {}
432                }
433            }
434
435            match &self.state.global_mode {
436                GlobalMode::Default | GlobalMode::Help | GlobalMode::Settings { .. } => {}
437                GlobalMode::Exiting { shutdown_task } => {
438                    if shutdown_task.is_finished() {
439                        break;
440                    }
441                }
442            }
443
444            if self.last_tick.elapsed() >= Self::TICK_RATE {
445                self.on_tick();
446                self.last_tick = Instant::now();
447            }
448        }
449
450        Ok(())
451    }
452
453    async fn run(&mut self, terminal: Terminal<impl Backend>) -> Result<()> {
454        self.render_loop(terminal).await?;
455
456        // At this point the user has exited the loop, so shut down the application.
457        ratatui::restore();
458        execute!(stdout(), DisableMouseCapture)?;
459
460        Ok(())
461    }
462}
463
464impl Widget for &mut App {
465    /// Render the whole app.
466    fn render(self, area: Rect, buf: &mut Buffer) {
467        // Create a space for header, room list and timeline and the footer.
468        let vertical =
469            Layout::vertical([Constraint::Length(2), Constraint::Min(0), Constraint::Length(1)]);
470        let [header_area, rest_area, status_area] = vertical.areas(area);
471
472        // Create two chunks with equal horizontal screen space. One for the list and
473        // the other for the info block.
474        let horizontal =
475            Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(75)]);
476        let [room_list_area, room_view_area] = horizontal.areas(rest_area);
477
478        self.render_title(header_area, buf);
479        self.room_list.render(room_list_area, buf);
480        self.room_view.render(room_view_area, buf);
481        self.status.render(status_area, buf, &mut self.state);
482
483        match &mut self.state.global_mode {
484            GlobalMode::Default => {}
485            GlobalMode::Exiting { .. } => {
486                Clear.render(rest_area, buf);
487                let centered = create_centered_throbber_area(area);
488                let throbber = Throbber::default()
489                    .label("Exiting")
490                    .throbber_set(throbber_widgets_tui::BRAILLE_EIGHT_DOUBLE);
491                StatefulWidget::render(throbber, centered, buf, &mut self.state.throbber_state);
492            }
493            GlobalMode::Settings { view } => {
494                view.render(area, buf);
495            }
496            GlobalMode::Help => {
497                let mut help_view = HelpView::new();
498                help_view.render(area, buf);
499            }
500        }
501    }
502}
503
504impl App {
505    /// Render the top square (title of the program).
506    fn render_title(&self, area: Rect, buf: &mut Buffer) {
507        Paragraph::new("Multiverse").bold().centered().render(area, buf);
508    }
509}
510
511/// Configure the client so it's ready for sync'ing.
512///
513/// Will log in or reuse a previous session.
514async fn configure_client(cli: Cli) -> Result<Client> {
515    let Cli { server_name, session_path, proxy } = cli;
516
517    let mut client_builder = Client::builder()
518        .store_config(
519            StoreConfig::new("multiverse".to_owned())
520                .crypto_store(SqliteCryptoStore::open(session_path.join("crypto"), None).await?)
521                .state_store(SqliteStateStore::open(session_path.join("state"), None).await?)
522                .event_cache_store(
523                    SqliteEventCacheStore::open(session_path.join("cache"), None).await?,
524                ),
525        )
526        .server_name_or_homeserver_url(&server_name)
527        .with_encryption_settings(EncryptionSettings {
528            auto_enable_cross_signing: true,
529            backup_download_strategy: BackupDownloadStrategy::AfterDecryptionFailure,
530            auto_enable_backups: true,
531        });
532
533    if let Some(proxy_url) = proxy {
534        client_builder = client_builder.proxy(proxy_url).disable_ssl_verification();
535    }
536
537    let client = client_builder.build().await?;
538
539    // Try reading a session, otherwise create a new one.
540    log_in_or_restore_session(&client, &session_path).await?;
541
542    Ok(client)
543}
544
545async fn log_in_or_restore_session(client: &Client, session_path: &Path) -> Result<()> {
546    let session_path = session_path.join("session.json");
547
548    if let Ok(serialized) = std::fs::read_to_string(&session_path) {
549        let session: MatrixSession = serde_json::from_str(&serialized)?;
550        client.restore_session(session).await?;
551    } else {
552        login_with_password(client).await?;
553
554        // Immediately save the session to disk.
555        if let Some(session) = client.session() {
556            let AuthSession::Matrix(session) = session else {
557                panic!("unexpected OAuth 2.0 session")
558            };
559            let serialized = serde_json::to_string(&session)?;
560            std::fs::write(session_path, serialized)?;
561
562            println!("saved session");
563        }
564    }
565
566    Ok(())
567}
568
569/// Asks the user of a username and password, and try to login using the matrix
570/// auth with those.
571async fn login_with_password(client: &Client) -> Result<()> {
572    println!("Logging in with username and password…");
573
574    loop {
575        print!("\nUsername: ");
576        stdout().flush().expect("Unable to write to stdout");
577        let mut username = String::new();
578        io::stdin().read_line(&mut username).expect("Unable to read user input");
579        username = username.trim().to_owned();
580
581        let password = rpassword::prompt_password("Password.")?;
582
583        match client.matrix_auth().login_username(&username, password.trim()).await {
584            Ok(_) => {
585                println!("Logged in as {username}");
586                break;
587            }
588            Err(error) => {
589                println!("Error logging in: {error}");
590                println!("Please try again\n");
591            }
592        }
593    }
594
595    Ok(())
596}