| 1 | #![allow(clippy::large_enum_variant)] |
| 2 | |
| 3 | use std::{ |
| 4 | collections::{HashMap, HashSet}, |
| 5 | io::{self, Write, stdout}, |
| 6 | path::{Path, PathBuf}, |
| 7 | sync::Arc, |
| 8 | time::{Duration, Instant}, |
| 9 | }; |
| 10 | |
| 11 | use clap::Parser; |
| 12 | use color_eyre::Result; |
| 13 | use crossterm::{ |
| 14 | event::{ |
| 15 | self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers, |
| 16 | }, |
| 17 | execute, |
| 18 | }; |
| 19 | use futures_util::{StreamExt as _, pin_mut}; |
| 20 | use imbl::Vector; |
| 21 | use layout::Flex; |
| 22 | use matrix_sdk::{ |
| 23 | AuthSession, Client, SqliteCryptoStore, SqliteEventCacheStore, SqliteStateStore, |
| 24 | 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 | search_index::{SearchIndexGuard, SearchIndexStoreKind}, |
| 31 | }; |
| 32 | use matrix_sdk_base::{RoomStateFilter, event_cache::store::EventCacheStoreLockGuard}; |
| 33 | use matrix_sdk_common::{cross_process_lock::CrossProcessLockConfig, locks::Mutex}; |
| 34 | use matrix_sdk_ui::{ |
| 35 | Timeline as SdkTimeline, |
| 36 | room_list_service::{self, State, filters::new_filter_non_left}, |
| 37 | sync_service::SyncService, |
| 38 | timeline::{RoomExt as _, TimelineFocus, TimelineItem}, |
| 39 | }; |
| 40 | use ratatui::{DefaultTerminal, prelude::*, style::palette::tailwind, widgets::*}; |
| 41 | use throbber_widgets_tui::{Throbber, ThrobberState}; |
| 42 | use tokio::{ |
| 43 | spawn, |
| 44 | sync::mpsc::{Receiver, Sender, channel, error::TryRecvError}, |
| 45 | task::JoinHandle, |
| 46 | time::timeout, |
| 47 | }; |
| 48 | use tracing::{debug, error, warn}; |
| 49 | use tracing_subscriber::EnvFilter; |
| 50 | use widgets::{ |
| 51 | recovery::create_centered_throbber_area, room_view::RoomView, settings::SettingsView, |
| 52 | }; |
| 53 | |
| 54 | use crate::widgets::{ |
| 55 | create_room::CreateRoomView, |
| 56 | help::HelpView, |
| 57 | room_list::{ExtraRoomInfo, RoomInfos, RoomList, Rooms}, |
| 58 | search::{ |
| 59 | indexing::{IndexingMessage, IndexingView}, |
| 60 | searching::SearchingView, |
| 61 | }, |
| 62 | status::Status, |
| 63 | }; |
| 64 | |
| 65 | mod widgets; |
| 66 | |
| 67 | const HEADER_BG: Color = tailwind::BLUE.c950; |
| 68 | const NORMAL_ROW_COLOR: Color = tailwind::SLATE.c950; |
| 69 | const ALT_ROW_COLOR: Color = tailwind::SLATE.c900; |
| 70 | const SELECTED_STYLE_FG: Color = tailwind::BLUE.c300; |
| 71 | const TEXT_COLOR: Color = tailwind::SLATE.c200; |
| 72 | |
| 73 | type Timelines = Arc<Mutex<HashMap<OwnedRoomId, Timeline>>>; |
| 74 | |
| 75 | #[derive(Debug, Parser)] |
| 76 | struct Cli { |
| 77 | /// The homeserver the client should connect to. |
| 78 | server_name: String, |
| 79 | |
| 80 | /// The path where session specific data should be stored. |
| 81 | #[clap(default_value = "/tmp/")] |
| 82 | session_path: PathBuf, |
| 83 | |
| 84 | /// Set the proxy that should be used for the connection. |
| 85 | #[clap(short, long, env = "PROXY")] |
| 86 | proxy: Option<Url>, |
| 87 | |
| 88 | /// Whether to *not* reload the `pos`ition sliding sync token from disk at |
| 89 | /// start or not, for the room list sliding sync. |
| 90 | /// |
| 91 | /// Set to false by default (i.e. reload the position from disk). |
| 92 | #[clap(short, long, default_value_t = false)] |
| 93 | dont_share_pos: bool, |
| 94 | } |
| 95 | |
| 96 | #[derive(Default)] |
| 97 | pub enum GlobalMode { |
| 98 | /// The default mode, no popout screen is opened. |
| 99 | #[default] |
| 100 | Default, |
| 101 | /// Mode where we have opened the help screen. |
| 102 | Help, |
| 103 | /// Mode where we have opened the settings screen. |
| 104 | Settings { view: SettingsView }, |
| 105 | /// Mode where we are shutting our tasks down and exiting multiverse. |
| 106 | Exiting { shutdown_task: JoinHandle<()> }, |
| 107 | /// Mode where we have opened the create room screen |
| 108 | CreateRoom { view: CreateRoomView }, |
| 109 | /// Mode where we have opened the search screen |
| 110 | Searching { view: SearchingView, is_global: bool }, |
| 111 | /// Mode where we have opened the indexing screen |
| 112 | Indexing { view: IndexingView }, |
| 113 | } |
| 114 | |
| 115 | /// Helper function to create a centered rect using up certain percentage of the |
| 116 | /// available rect `r` |
| 117 | fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect { |
| 118 | let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center); |
| 119 | let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center); |
| 120 | let [area] = vertical.areas(area); |
| 121 | let [area] = horizontal.areas(area); |
| 122 | area |
| 123 | } |
| 124 | |
| 125 | #[tokio::main] |
| 126 | async fn main() -> Result<()> { |
| 127 | let cli = Cli::parse(); |
| 128 | let file_writer = tracing_appender::rolling::hourly(&cli.session_path, "logs-"); |
| 129 | |
| 130 | tracing_subscriber::fmt() |
| 131 | .with_env_filter(EnvFilter::from_default_env()) |
| 132 | .with_ansi(false) |
| 133 | .with_writer(file_writer) |
| 134 | .init(); |
| 135 | |
| 136 | color_eyre::install()?; |
| 137 | |
| 138 | let share_pos = !cli.dont_share_pos; |
| 139 | let client = configure_client(cli).await?; |
| 140 | |
| 141 | // Watch for errors in background jobs. |
| 142 | spawn({ |
| 143 | let client = client.clone(); |
| 144 | async move { |
| 145 | let mut recv = client.task_monitor().subscribe(); |
| 146 | while let Ok(report) = recv.recv().await { |
| 147 | error!(name = report.task.name, ?report.reason, "A background task has crashed!"); |
| 148 | } |
| 149 | } |
| 150 | }); |
| 151 | |
| 152 | let event_cache = client.event_cache(); |
| 153 | event_cache.config_mut().experimental_auto_backpagination = true; |
| 154 | event_cache.subscribe()?; |
| 155 | |
| 156 | let terminal = ratatui::init(); |
| 157 | execute!(stdout(), EnableMouseCapture)?; |
| 158 | let mut app = App::new(client, share_pos).await?; |
| 159 | |
| 160 | app.run(terminal).await |
| 161 | } |
| 162 | |
| 163 | pub struct Timeline { |
| 164 | timeline: Arc<SdkTimeline>, |
| 165 | items: Arc<Mutex<Vector<Arc<TimelineItem>>>>, |
| 166 | task: JoinHandle<()>, |
| 167 | } |
| 168 | |
| 169 | #[derive(Default)] |
| 170 | pub struct AppState { |
| 171 | /// What popup are we showing that is covering the majority of the screen, |
| 172 | /// mainly used for help and settings screens. |
| 173 | global_mode: GlobalMode, |
| 174 | |
| 175 | /// State for a global throbber. |
| 176 | throbber_state: ThrobberState, |
| 177 | } |
| 178 | |
| 179 | struct App { |
| 180 | /// Reference to the main SDK client. |
| 181 | client: Client, |
| 182 | |
| 183 | /// The sync service used for synchronizing events. |
| 184 | sync_service: Arc<SyncService>, |
| 185 | |
| 186 | /// Timelines data structures for each room. |
| 187 | timelines: Timelines, |
| 188 | |
| 189 | /// The room list widget on the left-hand side of the screen. |
| 190 | room_list: RoomList, |
| 191 | |
| 192 | /// A view displaying the contents of the selected room, the widget on the |
| 193 | /// right-hand side of the screen. |
| 194 | room_view: RoomView, |
| 195 | |
| 196 | /// Task listening to room list service changes, and spawning timelines. |
| 197 | listen_task: JoinHandle<()>, |
| 198 | |
| 199 | /// Task that is indexing events for search. |
| 200 | indexing_task: JoinHandle<()>, |
| 201 | |
| 202 | /// Receiver that notifies when indexing is complete. |
| 203 | indexing_receiver: Receiver<(bool, IndexingMessage)>, |
| 204 | |
| 205 | /// The status widget at the bottom of the screen. |
| 206 | status: Status, |
| 207 | |
| 208 | state: AppState, |
| 209 | |
| 210 | last_tick: Instant, |
| 211 | } |
| 212 | |
| 213 | impl App { |
| 214 | const TICK_RATE: Duration = Duration::from_millis(250); |
| 215 | |
| 216 | async fn new(client: Client, share_pos: bool) -> Result<Self> { |
| 217 | let sync_service = |
| 218 | Arc::new(SyncService::builder(client.clone()).with_share_pos(share_pos).build().await?); |
| 219 | |
| 220 | let rooms = Rooms::default(); |
| 221 | let room_infos = RoomInfos::default(); |
| 222 | let timelines = Timelines::default(); |
| 223 | |
| 224 | let room_list_service = sync_service.room_list_service(); |
| 225 | let all_rooms = room_list_service.all_rooms().await?; |
| 226 | |
| 227 | let listen_task = spawn(Self::listen_task( |
| 228 | rooms.clone(), |
| 229 | room_infos.clone(), |
| 230 | timelines.clone(), |
| 231 | all_rooms, |
| 232 | )); |
| 233 | |
| 234 | // This will sync (with encryption) until an error happens or the program is |
| 235 | // stopped. |
| 236 | sync_service.start().await; |
| 237 | |
| 238 | let status = Status::new(); |
| 239 | let room_list = |
| 240 | RoomList::new(client.clone(), rooms, room_infos, sync_service.clone(), status.handle()); |
| 241 | |
| 242 | let room_view = RoomView::new(client.clone(), timelines.clone(), status.handle()); |
| 243 | |
| 244 | let (indexing_sender, indexing_receiver) = channel::<(bool, IndexingMessage)>(1024); |
| 245 | let indexing_task = |
| 246 | spawn(App::indexing_task(client.clone(), indexing_sender, sync_service.clone())); |
| 247 | |
| 248 | let indexing_view = IndexingView::new(); |
| 249 | |
| 250 | Ok(Self { |
| 251 | sync_service, |
| 252 | timelines, |
| 253 | room_list, |
| 254 | room_view, |
| 255 | client, |
| 256 | listen_task, |
| 257 | indexing_task, |
| 258 | indexing_receiver, |
| 259 | status, |
| 260 | state: AppState { |
| 261 | global_mode: GlobalMode::Indexing { view: indexing_view }, |
| 262 | ..Default::default() |
| 263 | }, |
| 264 | last_tick: Instant::now(), |
| 265 | }) |
| 266 | } |
| 267 | |
| 268 | async fn listen_task( |
| 269 | rooms: Rooms, |
| 270 | room_infos: RoomInfos, |
| 271 | timelines: Timelines, |
| 272 | all_rooms: room_list_service::RoomList, |
| 273 | ) { |
| 274 | let (stream, entries_controller) = all_rooms.entries_with_dynamic_adapters(50_000); |
| 275 | entries_controller.set_filter(Box::new(new_filter_non_left())); |
| 276 | |
| 277 | pin_mut!(stream); |
| 278 | |
| 279 | let mut previous_rooms = HashSet::new(); |
| 280 | |
| 281 | while let Some(diffs) = stream.next().await { |
| 282 | let all_rooms = { |
| 283 | // Apply the diffs to the list of room entries. |
| 284 | let mut rooms = rooms.lock(); |
| 285 | |
| 286 | for diff in diffs { |
| 287 | diff.apply(&mut rooms); |
| 288 | } |
| 289 | |
| 290 | // Collect rooms early to release the room entries list lock. |
| 291 | (*rooms).clone() |
| 292 | }; |
| 293 | |
| 294 | let mut new_rooms = HashMap::new(); |
| 295 | let mut new_timelines = Vec::new(); |
| 296 | |
| 297 | // Update all the room info for all rooms. |
| 298 | for room in all_rooms.iter() { |
| 299 | let raw_name = room.name(); |
| 300 | let display_name = |
| 301 | room.cached_display_name().map(|display_name| display_name.to_string()); |
| 302 | let is_dm = room |
| 303 | .is_direct() |
| 304 | .await |
| 305 | .map_err(|err| { |
| 306 | warn!("couldn't figure whether a room is a DM or not: {err}"); |
| 307 | }) |
| 308 | .ok(); |
| 309 | room_infos.lock().insert( |
| 310 | room.room_id().to_owned(), |
| 311 | ExtraRoomInfo { raw_name, display_name, is_dm }, |
| 312 | ); |
| 313 | } |
| 314 | |
| 315 | // Initialize all the new rooms. |
| 316 | for room in |
| 317 | all_rooms.into_iter().filter(|room| !previous_rooms.contains(room.room_id())) |
| 318 | { |
| 319 | // Initialize the timeline. |
| 320 | let Ok(timeline) = room |
| 321 | .timeline_builder() |
| 322 | .with_focus(TimelineFocus::Live { hide_threaded_events: true }) |
| 323 | .build() |
| 324 | .await |
| 325 | else { |
| 326 | error!("error when creating default timeline"); |
| 327 | continue; |
| 328 | }; |
| 329 | |
| 330 | // Save the timeline in the cache. |
| 331 | let (items, stream) = timeline.subscribe().await; |
| 332 | let items = Arc::new(Mutex::new(items)); |
| 333 | |
| 334 | // Spawn a timeline task that will listen to all the timeline item changes. |
| 335 | let i = items.clone(); |
| 336 | let timeline_task = spawn(async move { |
| 337 | pin_mut!(stream); |
| 338 | let items = i; |
| 339 | while let Some(diffs) = stream.next().await { |
| 340 | let mut items = items.lock(); |
| 341 | |
| 342 | for diff in diffs { |
| 343 | diff.apply(&mut items); |
| 344 | } |
| 345 | } |
| 346 | }); |
| 347 | |
| 348 | new_timelines.push(( |
| 349 | room.room_id().to_owned(), |
| 350 | Timeline { timeline: Arc::new(timeline), items, task: timeline_task }, |
| 351 | )); |
| 352 | |
| 353 | // Save the room list service room in the cache. |
| 354 | new_rooms.insert(room.room_id().to_owned(), room); |
| 355 | } |
| 356 | |
| 357 | previous_rooms.extend(new_rooms.into_keys()); |
| 358 | |
| 359 | timelines.lock().extend(new_timelines); |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | async fn wait_for_room_sync( |
| 364 | update_sender: &Sender<(bool, IndexingMessage)>, |
| 365 | sync_service: Arc<SyncService>, |
| 366 | ) { |
| 367 | let mut sync_subscriber = sync_service.room_list_service().state(); |
| 368 | |
| 369 | // Spin until there are rooms to index |
| 370 | while let Some(state) = sync_subscriber.next().await { |
| 371 | match state { |
| 372 | State::Running => return, |
| 373 | State::Terminated { from: _prev } => { |
| 374 | while let Err(e) = |
| 375 | update_sender.send((true, IndexingMessage::Progress(0))).await |
| 376 | { |
| 377 | debug!("Failed to send final message, trying again: {e:?}"); |
| 378 | } |
| 379 | return; |
| 380 | } |
| 381 | _ => { |
| 382 | debug!("Sync service not running. Waiting to start indexing. {state:?}"); |
| 383 | } |
| 384 | } |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | async fn index_event_cache( |
| 389 | client: &Client, |
| 390 | update_sender: &Sender<(bool, IndexingMessage)>, |
| 391 | store: &EventCacheStoreLockGuard, |
| 392 | search_index_guard: &mut SearchIndexGuard<'_>, |
| 393 | mut count: usize, |
| 394 | ) -> Result<usize, ()> { |
| 395 | for room in client.rooms_filtered(RoomStateFilter::JOINED.union(RoomStateFilter::LEFT)) { |
| 396 | let room_id = room.room_id(); |
| 397 | |
| 398 | let maybe_room_cache = room.event_cache().await; |
| 399 | let Ok((room_cache, _drop_handles)) = maybe_room_cache else { |
| 400 | warn!("Failed to get RoomEventCache: {maybe_room_cache:?}"); |
| 401 | continue; |
| 402 | }; |
| 403 | |
| 404 | let redaction_rules = room.clone_info().room_version_rules_or_default().redaction; |
| 405 | |
| 406 | let maybe_timeline_events = store.get_room_events(room_id, None, None).await; |
| 407 | let Ok(timeline_events) = maybe_timeline_events else { |
| 408 | warn!("Failed to get room's events: {maybe_timeline_events:?}"); |
| 409 | continue; |
| 410 | }; |
| 411 | |
| 412 | let no_of_events = timeline_events.len(); |
| 413 | |
| 414 | if let Err(err) = search_index_guard |
| 415 | .bulk_handle_timeline_event( |
| 416 | timeline_events.clone().into_iter(), |
| 417 | &room_cache, |
| 418 | room_id, |
| 419 | &redaction_rules, |
| 420 | ) |
| 421 | .await |
| 422 | { |
| 423 | error!("Failed to handle event for indexing: {err}"); |
| 424 | let mut error = Some(err); |
| 425 | while let Some(err) = error.take() { |
| 426 | if let Err(e) = |
| 427 | update_sender.send((true, IndexingMessage::Error(err.to_string()))).await |
| 428 | { |
| 429 | debug!("Failed to send final error message, trying again: {e:?}"); |
| 430 | } |
| 431 | } |
| 432 | return Err(()); |
| 433 | } |
| 434 | |
| 435 | count += no_of_events; |
| 436 | let _ = update_sender.send((false, IndexingMessage::Progress(count))).await; |
| 437 | } |
| 438 | Ok(count) |
| 439 | } |
| 440 | |
| 441 | async fn index_from_server( |
| 442 | client: &Client, |
| 443 | update_sender: &Sender<(bool, IndexingMessage)>, |
| 444 | search_index_guard: &mut SearchIndexGuard<'_>, |
| 445 | mut count: usize, |
| 446 | ) -> Result<usize, ()> { |
| 447 | let batch_size = 25; |
| 448 | |
| 449 | let mut rooms = client.rooms_filtered(RoomStateFilter::JOINED); |
| 450 | let mut idx = 0; |
| 451 | |
| 452 | while !rooms.is_empty() { |
| 453 | let room = &rooms[idx]; |
| 454 | |
| 455 | let room_id = room.room_id(); |
| 456 | |
| 457 | let maybe_room_cache = room.event_cache().await; |
| 458 | let Ok((room_cache, _drop_handles)) = maybe_room_cache else { |
| 459 | warn!("Failed to get RoomEventCache: {maybe_room_cache:?}"); |
| 460 | idx = (idx + 1) % rooms.len(); |
| 461 | continue; |
| 462 | }; |
| 463 | |
| 464 | let redaction_rules = room.clone_info().room_version_rules_or_default().redaction; |
| 465 | |
| 466 | let Ok(pagination) = room_cache.pagination().run_backwards_until(batch_size).await |
| 467 | else { |
| 468 | error!("Failed to backpaginate {room_id}"); |
| 469 | idx = (idx + 1) % rooms.len(); |
| 470 | continue; |
| 471 | }; |
| 472 | |
| 473 | let no_of_events = pagination.events.len(); |
| 474 | |
| 475 | if let Err(err) = search_index_guard |
| 476 | .bulk_handle_timeline_event( |
| 477 | pagination.events.clone().into_iter(), |
| 478 | &room_cache, |
| 479 | room_id, |
| 480 | &redaction_rules, |
| 481 | ) |
| 482 | .await |
| 483 | { |
| 484 | warn!("Failed to handle event for indexing: {err}"); |
| 485 | let mut error = Some(err); |
| 486 | while let Some(err) = error.take() { |
| 487 | if let Err(e) = |
| 488 | update_sender.send((true, IndexingMessage::Error(err.to_string()))).await |
| 489 | { |
| 490 | debug!("Failed to send final error message, trying again: {e:?}"); |
| 491 | } |
| 492 | } |
| 493 | return Err(()); |
| 494 | } |
| 495 | |
| 496 | count += no_of_events; |
| 497 | let _ = update_sender.send((false, IndexingMessage::Progress(count))).await; |
| 498 | |
| 499 | if pagination.reached_start { |
| 500 | rooms.remove(idx); |
| 501 | let len = rooms.len(); |
| 502 | if len > 0 { |
| 503 | idx %= len; |
| 504 | } |
| 505 | } else { |
| 506 | idx = (idx + 1) % rooms.len(); |
| 507 | } |
| 508 | } |
| 509 | Ok(count) |
| 510 | } |
| 511 | |
| 512 | /// The sender sends (progress, done?, error?). |
| 513 | async fn indexing_task( |
| 514 | client: Client, |
| 515 | update_sender: Sender<(bool, IndexingMessage)>, |
| 516 | sync_service: Arc<SyncService>, |
| 517 | ) { |
| 518 | if timeout(Duration::from_secs(30), App::wait_for_room_sync(&update_sender, sync_service)) |
| 519 | .await |
| 520 | .is_err() |
| 521 | { |
| 522 | debug!("Waiting for sync to run timed out. Quitting indexing task."); |
| 523 | return; |
| 524 | } |
| 525 | |
| 526 | let Ok(store) = client.event_cache_store().lock().await else { |
| 527 | error!("Failed to get EventCacheStore"); |
| 528 | return; |
| 529 | }; |
| 530 | |
| 531 | let mut search_index_guard = client.search_index().lock().await; |
| 532 | let count = 0; |
| 533 | |
| 534 | debug!("Start indexing from the event cache."); |
| 535 | |
| 536 | // First index everything in the cache |
| 537 | let Ok(count) = App::index_event_cache( |
| 538 | &client, |
| 539 | &update_sender, |
| 540 | store.as_clean().expect("Only one process should access the event cache store"), |
| 541 | &mut search_index_guard, |
| 542 | count, |
| 543 | ) |
| 544 | .await |
| 545 | else { |
| 546 | debug!("Quitting index task."); |
| 547 | return; |
| 548 | }; |
| 549 | |
| 550 | // Now index from the server |
| 551 | debug!("Start indexing from the server."); |
| 552 | |
| 553 | let Ok(count) = |
| 554 | App::index_from_server(&client, &update_sender, &mut search_index_guard, count).await |
| 555 | else { |
| 556 | debug!("Quitting index task."); |
| 557 | return; |
| 558 | }; |
| 559 | |
| 560 | while let Err(err) = update_sender.send((true, IndexingMessage::Progress(count))).await { |
| 561 | debug!("couldn't send final update {err}, trying again."); |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | fn set_global_mode(&mut self, mode: GlobalMode) { |
| 566 | self.state.global_mode = mode; |
| 567 | } |
| 568 | |
| 569 | async fn handle_global_event(&mut self, event: Event) -> Result<bool> { |
| 570 | use KeyCode::*; |
| 571 | |
| 572 | match event { |
| 573 | Event::Key(KeyEvent { code: F(1), modifiers: KeyModifiers::NONE, .. }) => { |
| 574 | self.set_global_mode(GlobalMode::Help) |
| 575 | } |
| 576 | |
| 577 | Event::Key(KeyEvent { code: F(10), modifiers: KeyModifiers::NONE, .. }) => self |
| 578 | .set_global_mode(GlobalMode::Settings { |
| 579 | view: SettingsView::new(self.client.clone(), self.sync_service.clone()), |
| 580 | }), |
| 581 | |
| 582 | Event::Key(KeyEvent { |
| 583 | code: Char('j') | Down, |
| 584 | modifiers: KeyModifiers::CONTROL, |
| 585 | .. |
| 586 | }) => { |
| 587 | self.room_list.next_room().await; |
| 588 | let room_id = self.room_list.get_selected_room_id(); |
| 589 | self.room_view.set_selected_room(room_id); |
| 590 | } |
| 591 | |
| 592 | Event::Key(KeyEvent { |
| 593 | code: Char('k') | Up, modifiers: KeyModifiers::CONTROL, .. |
| 594 | }) => { |
| 595 | self.room_list.previous_room().await; |
| 596 | let room_id = self.room_list.get_selected_room_id(); |
| 597 | self.room_view.set_selected_room(room_id); |
| 598 | } |
| 599 | |
| 600 | Event::Key(KeyEvent { code: Char('m'), modifiers: KeyModifiers::ALT, .. }) => { |
| 601 | self.room_view.mark_as_read().await |
| 602 | } |
| 603 | |
| 604 | Event::Key(KeyEvent { code: Char('q'), modifiers: KeyModifiers::CONTROL, .. }) => { |
| 605 | if !matches!(self.state.global_mode, GlobalMode::Default) { |
| 606 | self.set_global_mode(GlobalMode::Default); |
| 607 | } else { |
| 608 | return Ok(true); |
| 609 | } |
| 610 | } |
| 611 | |
| 612 | Event::Key(KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('r'), .. }) => { |
| 613 | self.set_global_mode(GlobalMode::CreateRoom { view: CreateRoomView::new() }) |
| 614 | } |
| 615 | |
| 616 | Event::Key(KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('s'), .. }) => self |
| 617 | .set_global_mode(GlobalMode::Searching { |
| 618 | view: SearchingView::new(false), |
| 619 | is_global: false, |
| 620 | }), |
| 621 | |
| 622 | Event::Key(KeyEvent { modifiers: KeyModifiers::CONTROL, code: Char('g'), .. }) => self |
| 623 | .set_global_mode(GlobalMode::Searching { |
| 624 | view: SearchingView::new(true), |
| 625 | is_global: true, |
| 626 | }), |
| 627 | |
| 628 | _ => self.room_view.handle_event(event).await, |
| 629 | } |
| 630 | |
| 631 | Ok(false) |
| 632 | } |
| 633 | |
| 634 | fn on_tick(&mut self) { |
| 635 | self.state.throbber_state.calc_next(); |
| 636 | |
| 637 | match &mut self.state.global_mode { |
| 638 | GlobalMode::Help |
| 639 | | GlobalMode::Default |
| 640 | | GlobalMode::CreateRoom { .. } |
| 641 | | GlobalMode::Searching { .. } |
| 642 | | GlobalMode::Exiting { .. } => {} |
| 643 | GlobalMode::Settings { view } => { |
| 644 | view.on_tick(); |
| 645 | } |
| 646 | GlobalMode::Indexing { view } => { |
| 647 | view.on_tick(); |
| 648 | } |
| 649 | } |
| 650 | } |
| 651 | |
| 652 | async fn render_loop(&mut self, mut terminal: DefaultTerminal) -> Result<()> { |
| 653 | use KeyCode::*; |
| 654 | |
| 655 | let mut check_channel = true; |
| 656 | |
| 657 | loop { |
| 658 | if check_channel { |
| 659 | match self.indexing_receiver.try_recv() { |
| 660 | Ok((done, message)) => { |
| 661 | if !matches!(message, IndexingMessage::Error(_)) && done { |
| 662 | self.set_global_mode(GlobalMode::Default); |
| 663 | } else if let GlobalMode::Indexing { view } = &mut self.state.global_mode { |
| 664 | view.set_message(message); |
| 665 | } |
| 666 | } |
| 667 | Err(TryRecvError::Disconnected) => check_channel = false, |
| 668 | Err(TryRecvError::Empty) => {} |
| 669 | } |
| 670 | } |
| 671 | |
| 672 | terminal.draw(|f| f.render_widget(&mut *self, f.area()))?; |
| 673 | |
| 674 | if event::poll(Duration::from_millis(100))? { |
| 675 | let event = event::read()?; |
| 676 | |
| 677 | match &mut self.state.global_mode { |
| 678 | GlobalMode::Default => { |
| 679 | if self.handle_global_event(event).await? { |
| 680 | let sync_service = self.sync_service.clone(); |
| 681 | let timelines = self.timelines.clone(); |
| 682 | let listen_task = self.listen_task.abort_handle(); |
| 683 | let indexing_task = self.indexing_task.abort_handle(); |
| 684 | |
| 685 | let shutdown_task = spawn(async move { |
| 686 | sync_service.stop().await; |
| 687 | |
| 688 | listen_task.abort(); |
| 689 | indexing_task.abort(); |
| 690 | |
| 691 | for timeline in timelines.lock().values() { |
| 692 | timeline.task.abort(); |
| 693 | } |
| 694 | }); |
| 695 | |
| 696 | self.set_global_mode(GlobalMode::Exiting { shutdown_task }); |
| 697 | } |
| 698 | } |
| 699 | GlobalMode::Help => { |
| 700 | if let Event::Key(key) = event |
| 701 | && let KeyModifiers::NONE = key.modifiers |
| 702 | && let Char('q') | Esc = key.code |
| 703 | { |
| 704 | self.set_global_mode(GlobalMode::Default) |
| 705 | } |
| 706 | } |
| 707 | GlobalMode::Settings { view } => { |
| 708 | if let Event::Key(key) = event |
| 709 | && view.handle_key_press(key).await |
| 710 | { |
| 711 | self.set_global_mode(GlobalMode::Default); |
| 712 | } |
| 713 | } |
| 714 | GlobalMode::CreateRoom { view } => { |
| 715 | if let Event::Key(key) = event |
| 716 | && let KeyModifiers::NONE = key.modifiers |
| 717 | { |
| 718 | match key.code { |
| 719 | Enter => { |
| 720 | if let Some(room_name) = view.get_text() { |
| 721 | let mut request = CreateRoomRequest::new(); |
| 722 | request.name = Some(room_name); |
| 723 | if let Err(err) = self |
| 724 | .sync_service |
| 725 | .room_list_service() |
| 726 | .client() |
| 727 | .create_room(request) |
| 728 | .await |
| 729 | { |
| 730 | error!("error while creating room: {err:?}"); |
| 731 | } |
| 732 | } |
| 733 | self.set_global_mode(GlobalMode::Default); |
| 734 | } |
| 735 | Esc => self.set_global_mode(GlobalMode::Default), |
| 736 | _ => view.handle_key_press(key), |
| 737 | } |
| 738 | } |
| 739 | } |
| 740 | GlobalMode::Searching { view, is_global } => { |
| 741 | if let Event::Key(key) = event { |
| 742 | match key.code { |
| 743 | Enter => { |
| 744 | if let Some(query) = view.get_text() { |
| 745 | if *is_global { |
| 746 | let mut search = |
| 747 | self.client.search_messages(query, 5).build(); |
| 748 | |
| 749 | let mut all_results = HashMap::new(); |
| 750 | loop { |
| 751 | let Ok(results) = search.next_events().await else { |
| 752 | continue; |
| 753 | }; |
| 754 | let Some(results) = results else { |
| 755 | break; |
| 756 | }; |
| 757 | for (room_id, event_id) in results { |
| 758 | all_results |
| 759 | .entry(room_id) |
| 760 | .or_insert_with(Vec::new) |
| 761 | .push(event_id); |
| 762 | } |
| 763 | } |
| 764 | |
| 765 | view.set_results( |
| 766 | all_results |
| 767 | .into_iter() |
| 768 | .map(|(room_id, events)| { |
| 769 | (Some(room_id), events) |
| 770 | }) |
| 771 | .collect(), |
| 772 | ); |
| 773 | } else if let Some((query, room)) = |
| 774 | view.get_text().zip(self.room_view.room()) |
| 775 | { |
| 776 | let mut room_search = room.search_messages(query, 5); |
| 777 | |
| 778 | let mut all_results = Vec::new(); |
| 779 | while let Some(results) = |
| 780 | room_search.next_events().await? |
| 781 | { |
| 782 | all_results.extend(results); |
| 783 | } |
| 784 | view.set_results(vec![(None, all_results)]); |
| 785 | } |
| 786 | } |
| 787 | } |
| 788 | |
| 789 | Esc => self.set_global_mode(GlobalMode::Default), |
| 790 | |
| 791 | Up => view.list_state.previous(), |
| 792 | |
| 793 | Down => view.list_state.next(), |
| 794 | |
| 795 | _ => view.handle_key_press(key), |
| 796 | } |
| 797 | } |
| 798 | } |
| 799 | GlobalMode::Indexing { .. } => { |
| 800 | if let Event::Key(key) = event |
| 801 | && let KeyModifiers::NONE = key.modifiers |
| 802 | && let Esc = key.code |
| 803 | { |
| 804 | self.indexing_task.abort(); |
| 805 | self.set_global_mode(GlobalMode::Default); |
| 806 | } |
| 807 | } |
| 808 | GlobalMode::Exiting { .. } => {} |
| 809 | } |
| 810 | } |
| 811 | |
| 812 | match &self.state.global_mode { |
| 813 | GlobalMode::Default |
| 814 | | GlobalMode::Help |
| 815 | | GlobalMode::CreateRoom { .. } |
| 816 | | GlobalMode::Searching { .. } |
| 817 | | GlobalMode::Indexing { .. } |
| 818 | | GlobalMode::Settings { .. } => {} |
| 819 | GlobalMode::Exiting { shutdown_task } => { |
| 820 | if shutdown_task.is_finished() { |
| 821 | break; |
| 822 | } |
| 823 | } |
| 824 | } |
| 825 | |
| 826 | if self.last_tick.elapsed() >= Self::TICK_RATE { |
| 827 | self.on_tick(); |
| 828 | self.last_tick = Instant::now(); |
| 829 | } |
| 830 | } |
| 831 | |
| 832 | Ok(()) |
| 833 | } |
| 834 | |
| 835 | async fn run(&mut self, terminal: DefaultTerminal) -> Result<()> { |
| 836 | self.render_loop(terminal).await?; |
| 837 | |
| 838 | // At this point the user has exited the loop, so shut down the application. |
| 839 | ratatui::restore(); |
| 840 | execute!(stdout(), DisableMouseCapture)?; |
| 841 | |
| 842 | Ok(()) |
| 843 | } |
| 844 | } |
| 845 | |
| 846 | impl Widget for &mut App { |
| 847 | /// Render the whole app. |
| 848 | fn render(self, area: Rect, buf: &mut Buffer) { |
| 849 | // Create a space for header, room list and timeline and the footer. |
| 850 | let vertical = |
| 851 | Layout::vertical([Constraint::Length(2), Constraint::Min(0), Constraint::Length(1)]); |
| 852 | let [header_area, rest_area, status_area] = vertical.areas(area); |
| 853 | |
| 854 | // Create two chunks with equal horizontal screen space. One for the list and |
| 855 | // the other for the info block. |
| 856 | let horizontal = |
| 857 | Layout::horizontal([Constraint::Percentage(25), Constraint::Percentage(75)]); |
| 858 | let [room_list_area, room_view_area] = horizontal.areas(rest_area); |
| 859 | |
| 860 | self.render_title(header_area, buf); |
| 861 | self.room_list.render(room_list_area, buf); |
| 862 | self.room_view.render(room_view_area, buf); |
| 863 | self.status.render(status_area, buf, &mut self.state); |
| 864 | |
| 865 | match &mut self.state.global_mode { |
| 866 | GlobalMode::Default => {} |
| 867 | GlobalMode::Exiting { .. } => { |
| 868 | Clear.render(rest_area, buf); |
| 869 | let centered = create_centered_throbber_area(area); |
| 870 | let throbber = Throbber::default() |
| 871 | .label("Exiting") |
| 872 | .throbber_set(throbber_widgets_tui::BRAILLE_EIGHT_DOUBLE); |
| 873 | StatefulWidget::render(throbber, centered, buf, &mut self.state.throbber_state); |
| 874 | } |
| 875 | GlobalMode::Settings { view } => { |
| 876 | view.render(area, buf); |
| 877 | } |
| 878 | GlobalMode::Help => { |
| 879 | let mut help_view = HelpView::new(); |
| 880 | help_view.render(area, buf); |
| 881 | } |
| 882 | GlobalMode::CreateRoom { view } => { |
| 883 | view.render(area, buf); |
| 884 | } |
| 885 | GlobalMode::Searching { view, .. } => { |
| 886 | view.render(room_view_area, buf); |
| 887 | } |
| 888 | GlobalMode::Indexing { view } => { |
| 889 | view.render(area, buf); |
| 890 | } |
| 891 | } |
| 892 | } |
| 893 | } |
| 894 | |
| 895 | impl App { |
| 896 | /// Render the top square (title of the program). |
| 897 | fn render_title(&self, area: Rect, buf: &mut Buffer) { |
| 898 | Paragraph::new("Multiverse").bold().centered().render(area, buf); |
| 899 | } |
| 900 | } |
| 901 | |
| 902 | /// Configure the client so it's ready for sync'ing. |
| 903 | /// |
| 904 | /// Will log in or reuse a previous session. |
| 905 | async fn configure_client(cli: Cli) -> Result<Client> { |
| 906 | let Cli { server_name, session_path, proxy, dont_share_pos: _ } = cli; |
| 907 | |
| 908 | let mut client_builder = Client::builder() |
| 909 | .store_config( |
| 910 | StoreConfig::new(CrossProcessLockConfig::multi_process("multiverse")) |
| 911 | .crypto_store(SqliteCryptoStore::open(session_path.join("crypto"), None).await?) |
| 912 | .state_store(SqliteStateStore::open(session_path.join("state"), None).await?) |
| 913 | .event_cache_store( |
| 914 | SqliteEventCacheStore::open(session_path.join("cache"), None).await?, |
| 915 | ), |
| 916 | ) |
| 917 | .server_name_or_homeserver_url(&server_name) |
| 918 | .with_encryption_settings(EncryptionSettings { |
| 919 | auto_enable_cross_signing: true, |
| 920 | backup_download_strategy: BackupDownloadStrategy::AfterDecryptionFailure, |
| 921 | auto_enable_backups: true, |
| 922 | }) |
| 923 | .with_enable_share_history_on_invite(true) |
| 924 | .with_threading_support(ThreadingSupport::Enabled { with_subscriptions: true }) |
| 925 | .search_index_store(SearchIndexStoreKind::UnencryptedDirectory( |
| 926 | session_path.join("indexData"), |
| 927 | )); |
| 928 | |
| 929 | if let Some(proxy_url) = proxy { |
| 930 | client_builder = client_builder.proxy(proxy_url).disable_ssl_verification(); |
| 931 | } |
| 932 | |
| 933 | let client = client_builder.build().await?; |
| 934 | |
| 935 | // Try reading a session, otherwise create a new one. |
| 936 | log_in_or_restore_session(&client, &session_path).await?; |
| 937 | |
| 938 | Ok(client) |
| 939 | } |
| 940 | |
| 941 | async fn log_in_or_restore_session(client: &Client, session_path: &Path) -> Result<()> { |
| 942 | let session_path = session_path.join("session.json"); |
| 943 | |
| 944 | if let Ok(serialized) = std::fs::read_to_string(&session_path) { |
| 945 | let session: MatrixSession = serde_json::from_str(&serialized)?; |
| 946 | client.restore_session(session).await?; |
| 947 | } else { |
| 948 | login_with_password(client).await?; |
| 949 | |
| 950 | // Immediately save the session to disk. |
| 951 | if let Some(session) = client.session() { |
| 952 | let AuthSession::Matrix(session) = session else { |
| 953 | panic!("unexpected OAuth 2.0 session") |
| 954 | }; |
| 955 | let serialized = serde_json::to_string(&session)?; |
| 956 | std::fs::write(session_path, serialized)?; |
| 957 | |
| 958 | println!("saved session"); |
| 959 | } |
| 960 | } |
| 961 | |
| 962 | Ok(()) |
| 963 | } |
| 964 | |
| 965 | /// Asks the user of a username and password, and try to login using the matrix |
| 966 | /// auth with those. |
| 967 | async fn login_with_password(client: &Client) -> Result<()> { |
| 968 | println!("Logging in with username and password…"); |
| 969 | |
| 970 | loop { |
| 971 | print!("\nUsername: "); |
| 972 | stdout().flush().expect("Unable to write to stdout"); |
| 973 | let mut username = String::new(); |
| 974 | io::stdin().read_line(&mut username).expect("Unable to read user input"); |
| 975 | username = username.trim().to_owned(); |
| 976 | |
| 977 | let password = rpassword::prompt_password("Password.")?; |
| 978 | |
| 979 | match client.matrix_auth().login_username(&username, password.trim()).await { |
| 980 | Ok(_) => { |
| 981 | println!("Logged in as {username}"); |
| 982 | break; |
| 983 | } |
| 984 | Err(error) => { |
| 985 | println!("Error logging in: {error}"); |
| 986 | println!("Please try again\n"); |
| 987 | } |
| 988 | } |
| 989 | } |
| 990 | |
| 991 | Ok(()) |
| 992 | } |