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 server_name: String,
69
70 #[clap(default_value = "/tmp/")]
72 session_path: PathBuf,
73
74 #[clap(short, long, env = "PROXY")]
76 proxy: Option<Url>,
77}
78
79#[derive(Default)]
80pub enum GlobalMode {
81 #[default]
83 Default,
84 Help,
86 Settings { view: SettingsView },
88 Exiting { shutdown_task: JoinHandle<()> },
90 CreateRoom { view: CreateRoomView },
92 Searching { view: SearchingView },
94}
95
96fn 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 global_mode: GlobalMode,
142
143 throbber_state: ThrobberState,
145}
146
147struct App {
148 client: Client,
150
151 sync_service: Arc<SyncService>,
153
154 timelines: Timelines,
156
157 room_list: RoomList,
159
160 room_view: RoomView,
163
164 listen_task: JoinHandle<()>,
166
167 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 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 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 mut new_rooms = HashMap::new();
245 let mut new_timelines = Vec::new();
246
247 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 for room in
267 all_rooms.into_iter().filter(|room| !previous_rooms.contains(room.room_id()))
268 {
269 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 let (items, stream) = timeline.subscribe().await;
282 let items = Arc::new(Mutex::new(items));
283
284 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 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 ratatui::restore();
513 execute!(stdout(), DisableMouseCapture)?;
514
515 Ok(())
516 }
517}
518
519impl Widget for &mut App {
520 fn render(self, area: Rect, buf: &mut Buffer) {
522 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 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 fn render_title(&self, area: Rect, buf: &mut Buffer) {
568 Paragraph::new("Multiverse").bold().centered().render(area, buf);
569 }
570}
571
572async 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 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 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
633async 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}