multiverse/
main.rs

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