multiverse/
main.rs

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