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 server_name: String,
67
68 #[clap(default_value = "/tmp/")]
70 session_path: PathBuf,
71
72 #[clap(short, long, env = "PROXY")]
74 proxy: Option<Url>,
75}
76
77#[derive(Default)]
78pub enum GlobalMode {
79 #[default]
81 Default,
82 Help,
84 Settings { view: SettingsView },
86 Exiting { shutdown_task: JoinHandle<()> },
88}
89
90fn 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 global_mode: GlobalMode,
136
137 throbber_state: ThrobberState,
139}
140
141struct App {
142 client: Client,
144
145 sync_service: Arc<SyncService>,
147
148 timelines: Timelines,
150
151 room_list: RoomList,
153
154 room_view: RoomView,
157
158 listen_task: JoinHandle<()>,
160
161 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 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 let mut rooms = rooms.lock();
235
236 for diff in diffs {
237 diff.apply(&mut rooms);
238 }
239
240 (*rooms).clone()
242 };
243
244 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 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 for ui_room in
272 all_rooms.into_iter().filter(|room| !previous_ui_rooms.contains_key(room.room_id()))
273 {
274 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 let (items, stream) = timeline.subscribe().await;
293 let items = Arc::new(Mutex::new(items));
294
295 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 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 ratatui::restore();
458 execute!(stdout(), DisableMouseCapture)?;
459
460 Ok(())
461 }
462}
463
464impl Widget for &mut App {
465 fn render(self, area: Rect, buf: &mut Buffer) {
467 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 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 fn render_title(&self, area: Rect, buf: &mut Buffer) {
507 Paragraph::new("Multiverse").bold().centered().render(area, buf);
508 }
509}
510
511async 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 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 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
569async 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}