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 server_name: String,
60
61 #[clap(default_value = "/tmp/")]
63 session_path: PathBuf,
64
65 #[clap(short, long, env = "PROXY")]
67 proxy: Option<Url>,
68}
69
70#[derive(Default)]
71pub enum GlobalMode {
72 #[default]
74 Default,
75 Help,
77 Settings { view: SettingsView },
79 Exiting { shutdown_task: JoinHandle<()> },
81}
82
83fn 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 global_mode: GlobalMode,
129
130 throbber_state: ThrobberState,
132}
133
134struct App {
135 client: Client,
137
138 sync_service: Arc<SyncService>,
140
141 timelines: Timelines,
143
144 room_list: RoomList,
146
147 room_view: RoomView,
150
151 listen_task: JoinHandle<()>,
153
154 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 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 let mut rooms = rooms.lock();
228
229 for diff in diffs {
230 diff.apply(&mut rooms);
231 }
232
233 (*rooms).clone()
235 };
236
237 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 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 for ui_room in
265 all_rooms.into_iter().filter(|room| !previous_ui_rooms.contains_key(room.room_id()))
266 {
267 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 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 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 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 ratatui::restore();
435
436 Ok(())
437 }
438}
439
440impl Widget for &mut App {
441 fn render(self, area: Rect, buf: &mut Buffer) {
443 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 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 fn render_title(&self, area: Rect, buf: &mut Buffer) {
483 Paragraph::new("Multiverse").bold().centered().render(area, buf);
484 }
485}
486
487async 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 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 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
545async 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}