1use std::{
2 collections::HashMap,
3 io::{self, stdout, Write},
4 path::{Path, PathBuf},
5 sync::{Arc, Mutex},
6 time::Duration,
7};
8
9use clap::Parser;
10use color_eyre::Result;
11use crossterm::event::{self, Event, KeyCode, KeyEventKind};
12use futures_util::{pin_mut, StreamExt as _};
13use imbl::Vector;
14use matrix_sdk::{
15 authentication::matrix::MatrixSession,
16 config::StoreConfig,
17 encryption::{BackupDownloadStrategy, EncryptionSettings},
18 reqwest::Url,
19 ruma::{
20 api::client::receipt::create_receipt::v3::ReceiptType,
21 events::room::message::{MessageType, RoomMessageEventContent},
22 MilliSecondsSinceUnixEpoch, OwnedRoomId, RoomId,
23 },
24 sleep::sleep,
25 AuthSession, Client, OwnedServerName, SqliteCryptoStore, SqliteEventCacheStore,
26 SqliteStateStore,
27};
28use matrix_sdk_ui::{
29 room_list_service::{self, filters::new_filter_non_left},
30 sync_service::SyncService,
31 timeline::{TimelineItem, TimelineItemContent, TimelineItemKind, VirtualTimelineItem},
32 Timeline as SdkTimeline,
33};
34use ratatui::{prelude::*, style::palette::tailwind, widgets::*};
35use tokio::{runtime::Handle, spawn, task::JoinHandle};
36use tracing::{error, warn};
37use tracing_subscriber::EnvFilter;
38
39const HEADER_BG: Color = tailwind::BLUE.c950;
40const NORMAL_ROW_COLOR: Color = tailwind::SLATE.c950;
41const ALT_ROW_COLOR: Color = tailwind::SLATE.c900;
42const SELECTED_STYLE_FG: Color = tailwind::BLUE.c300;
43const TEXT_COLOR: Color = tailwind::SLATE.c200;
44
45#[derive(Debug, Parser)]
46struct Cli {
47 server_name: OwnedServerName,
49
50 #[clap(default_value = "/tmp/")]
52 session_path: PathBuf,
53
54 #[clap(short, long, env = "PROXY")]
56 proxy: Option<Url>,
57}
58
59#[tokio::main]
60async fn main() -> Result<()> {
61 let file_writer = tracing_appender::rolling::hourly("/tmp/", "logs-");
62
63 tracing_subscriber::fmt()
64 .with_env_filter(EnvFilter::from_default_env())
65 .with_ansi(false)
66 .with_writer(file_writer)
67 .init();
68
69 color_eyre::install()?;
70
71 let cli = Cli::parse();
72 let client = configure_client(cli).await?;
73
74 let event_cache = client.event_cache();
75 event_cache.subscribe().unwrap();
76 event_cache.enable_storage().unwrap();
77
78 let terminal = ratatui::init();
79 let mut app = App::new(client).await?;
80
81 app.run(terminal).await
82}
83
84#[derive(Default)]
85struct StatefulList<T> {
86 state: ListState,
87 items: Arc<Mutex<Vector<T>>>,
88}
89
90#[derive(Default, PartialEq)]
91enum DetailsMode {
92 ReadReceipts,
93 #[default]
94 TimelineItems,
95 Events,
96 LinkedChunk,
97}
98
99struct Timeline {
100 timeline: Arc<SdkTimeline>,
101 items: Arc<Mutex<Vector<Arc<TimelineItem>>>>,
102 task: JoinHandle<()>,
103}
104
105#[derive(Clone)]
107struct ExtraRoomInfo {
108 raw_name: Option<String>,
110
111 display_name: Option<String>,
113
114 is_dm: Option<bool>,
116}
117
118struct App {
119 client: Client,
121
122 sync_service: Arc<SyncService>,
124
125 ui_rooms: Arc<Mutex<HashMap<OwnedRoomId, room_list_service::Room>>>,
127
128 timelines: Arc<Mutex<HashMap<OwnedRoomId, Timeline>>>,
130
131 room_list_rooms: StatefulList<room_list_service::Room>,
133
134 room_info: Arc<Mutex<HashMap<OwnedRoomId, ExtraRoomInfo>>>,
136
137 listen_task: JoinHandle<()>,
139
140 last_status_message: Arc<Mutex<Option<String>>>,
142
143 clear_status_message: Option<JoinHandle<()>>,
145
146 details_mode: DetailsMode,
148
149 current_room_subscription: Option<room_list_service::Room>,
151
152 current_pagination: Arc<Mutex<Option<JoinHandle<()>>>>,
153}
154
155impl App {
156 async fn new(client: Client) -> Result<Self> {
157 let sync_service = Arc::new(SyncService::builder(client.clone()).build().await?);
158
159 let rooms = Arc::new(Mutex::new(Vector::<room_list_service::Room>::new()));
160 let room_infos: Arc<Mutex<HashMap<OwnedRoomId, ExtraRoomInfo>>> =
161 Arc::new(Mutex::new(Default::default()));
162 let ui_rooms: Arc<Mutex<HashMap<OwnedRoomId, room_list_service::Room>>> =
163 Default::default();
164 let timelines = Arc::new(Mutex::new(HashMap::new()));
165
166 let r = rooms.clone();
167 let ri = room_infos.clone();
168 let ur = ui_rooms.clone();
169 let t = timelines.clone();
170
171 let room_list_service = sync_service.room_list_service();
172 let all_rooms = room_list_service.all_rooms().await?;
173
174 let listen_task = spawn(async move {
175 let rooms = r;
176 let room_infos = ri;
177 let ui_rooms = ur;
178 let timelines = t;
179
180 let (stream, entries_controller) = all_rooms.entries_with_dynamic_adapters(50_000);
181 entries_controller.set_filter(Box::new(new_filter_non_left()));
182
183 pin_mut!(stream);
184
185 while let Some(diffs) = stream.next().await {
186 let all_rooms = {
187 let mut rooms = rooms.lock().unwrap();
189
190 for diff in diffs {
191 diff.apply(&mut rooms);
192 }
193
194 (*rooms).clone()
196 };
197
198 let previous_ui_rooms = ui_rooms.lock().unwrap().clone();
203
204 let mut new_ui_rooms = HashMap::new();
205 let mut new_timelines = Vec::new();
206
207 for room in all_rooms.iter() {
209 let raw_name = room.name();
210 let display_name = room.cached_display_name();
211 let is_dm = room
212 .is_direct()
213 .await
214 .map_err(|err| {
215 warn!("couldn't figure whether a room is a DM or not: {err}");
216 })
217 .ok();
218 room_infos.lock().unwrap().insert(
219 room.room_id().to_owned(),
220 ExtraRoomInfo { raw_name, display_name, is_dm },
221 );
222 }
223
224 for ui_room in all_rooms
226 .into_iter()
227 .filter(|room| !previous_ui_rooms.contains_key(room.room_id()))
228 {
229 let builder = match ui_room.default_room_timeline_builder().await {
231 Ok(builder) => builder,
232 Err(err) => {
233 error!("error when getting default timeline builder: {err}");
234 continue;
235 }
236 };
237
238 if let Err(err) = ui_room.init_timeline_with_builder(builder).await {
239 error!("error when creating default timeline: {err}");
240 continue;
241 }
242
243 let sdk_timeline = ui_room.timeline().unwrap();
245 let (items, stream) = sdk_timeline.subscribe().await;
246 let items = Arc::new(Mutex::new(items));
247
248 let i = items.clone();
250 let timeline_task = spawn(async move {
251 pin_mut!(stream);
252 let items = i;
253 while let Some(diffs) = stream.next().await {
254 let mut items = items.lock().unwrap();
255
256 for diff in diffs {
257 diff.apply(&mut items);
258 }
259 }
260 });
261
262 new_timelines.push((
263 ui_room.room_id().to_owned(),
264 Timeline { timeline: sdk_timeline, items, task: timeline_task },
265 ));
266
267 new_ui_rooms.insert(ui_room.room_id().to_owned(), ui_room);
269 }
270
271 ui_rooms.lock().unwrap().extend(new_ui_rooms);
272 timelines.lock().unwrap().extend(new_timelines);
273 }
274 });
275
276 sync_service.start().await;
279
280 Ok(Self {
281 sync_service,
282 room_list_rooms: StatefulList { state: Default::default(), items: rooms },
283 room_info: room_infos,
284 client,
285 listen_task,
286 last_status_message: Default::default(),
287 clear_status_message: None,
288 ui_rooms,
289 details_mode: Default::default(),
290 timelines,
291 current_room_subscription: None,
292 current_pagination: Default::default(),
293 })
294 }
295}
296
297impl App {
298 fn set_status_message(&mut self, status: String) {
301 if let Some(handle) = self.clear_status_message.take() {
302 handle.abort();
304 }
305
306 *self.last_status_message.lock().unwrap() = Some(status);
307
308 let message = self.last_status_message.clone();
309 self.clear_status_message = Some(spawn(async move {
310 sleep(Duration::from_secs(4)).await;
312
313 *message.lock().unwrap() = None;
314 }));
315 }
316
317 async fn mark_as_read(&mut self) {
319 let Some(room) = self
320 .get_selected_room_id(None)
321 .and_then(|room_id| self.ui_rooms.lock().unwrap().get(&room_id).cloned())
322 else {
323 self.set_status_message("missing room or nothing to show".to_owned());
324 return;
325 };
326
327 match room.timeline().unwrap().mark_as_read(ReceiptType::Read).await {
329 Ok(did) => {
330 self.set_status_message(format!(
331 "did {}send a read receipt!",
332 if did { "" } else { "not " }
333 ));
334 }
335 Err(err) => {
336 self.set_status_message(format!("error when marking a room as read: {err}",));
337 }
338 }
339 }
340
341 async fn toggle_reaction_to_latest_msg(&mut self) {
342 let selected = self.get_selected_room_id(None);
343
344 if let Some((sdk_timeline, items)) = selected.and_then(|room_id| {
345 self.timelines
346 .lock()
347 .unwrap()
348 .get(&room_id)
349 .map(|timeline| (timeline.timeline.clone(), timeline.items.clone()))
350 }) {
351 let item_id = {
353 let items = items.lock().unwrap();
354 items.iter().rev().find_map(|it| {
355 it.as_event()
356 .and_then(|ev| ev.content().as_message().is_some().then(|| ev.identifier()))
357 })
358 };
359
360 if let Some(item_id) = item_id {
362 match sdk_timeline.toggle_reaction(&item_id, "🥰").await {
363 Ok(_) => {
364 self.set_status_message("reaction sent!".to_owned());
365 }
366 Err(err) => self.set_status_message(format!("error when reacting: {err}")),
367 }
368 } else {
369 self.set_status_message("no item to react to".to_owned());
370 }
371 } else {
372 self.set_status_message("missing timeline for room".to_owned());
373 };
374 }
375
376 fn back_paginate(&mut self) {
379 let Some(sdk_timeline) = self.get_selected_room_id(None).and_then(|room_id| {
380 self.timelines.lock().unwrap().get(&room_id).map(|timeline| timeline.timeline.clone())
381 }) else {
382 self.set_status_message("missing timeline for room".to_owned());
383 return;
384 };
385
386 let mut pagination = self.current_pagination.lock().unwrap();
387
388 if let Some(prev) = pagination.take() {
390 prev.abort();
391 }
392
393 *pagination = Some(spawn(async move {
396 if let Err(err) = sdk_timeline.paginate_backwards(20).await {
397 error!("Error during backpagination: {err}")
403 }
404 }));
405 }
406
407 fn get_selected_room_id(&self, selected: Option<usize>) -> Option<OwnedRoomId> {
409 let selected = selected.or_else(|| self.room_list_rooms.state.selected())?;
410
411 self.room_list_rooms
412 .items
413 .lock()
414 .unwrap()
415 .get(selected)
416 .cloned()
417 .map(|room| room.room_id().to_owned())
418 }
419
420 fn subscribe_to_selected_room(&mut self, selected: usize) {
421 self.current_room_subscription.take();
423
424 if let Some(room) = self
426 .get_selected_room_id(Some(selected))
427 .and_then(|room_id| self.ui_rooms.lock().unwrap().get(&room_id).cloned())
428 {
429 self.sync_service.room_list_service().subscribe_to_rooms(&[room.room_id()]);
430 self.current_room_subscription = Some(room);
431 }
432 }
433
434 async fn render_loop(&mut self, mut terminal: Terminal<impl Backend>) -> Result<()> {
435 loop {
436 terminal.draw(|f| f.render_widget(&mut *self, f.area()))?;
437
438 if event::poll(Duration::from_millis(16))? {
439 if let Event::Key(key) = event::read()? {
440 if key.kind == KeyEventKind::Press {
441 use KeyCode::*;
442 match key.code {
443 Char('q') | Esc => return Ok(()),
444
445 Char('j') | Down => {
446 if let Some(i) = self.room_list_rooms.next() {
447 self.subscribe_to_selected_room(i);
448 }
449 }
450
451 Char('k') | Up => {
452 if let Some(i) = self.room_list_rooms.previous() {
453 self.subscribe_to_selected_room(i);
454 }
455 }
456
457 Char('s') => self.sync_service.start().await,
458 Char('S') => self.sync_service.stop().await,
459
460 Char('Q') => {
461 let q = self.client.send_queue();
462 let enabled = q.is_enabled();
463 q.set_enabled(!enabled).await;
464 }
465
466 Char('M') => {
467 let selected = self.get_selected_room_id(None);
468
469 if let Some(sdk_timeline) = selected.and_then(|room_id| {
470 self.timelines
471 .lock()
472 .unwrap()
473 .get(&room_id)
474 .map(|timeline| timeline.timeline.clone())
475 }) {
476 match sdk_timeline
477 .send(
478 RoomMessageEventContent::text_plain(format!(
479 "hey {}",
480 MilliSecondsSinceUnixEpoch::now().get()
481 ))
482 .into(),
483 )
484 .await
485 {
486 Ok(_) => {
487 self.set_status_message("message sent!".to_owned());
488 }
489 Err(err) => {
490 self.set_status_message(format!(
491 "error when sending event: {err}"
492 ));
493 }
494 }
495 } else {
496 self.set_status_message("missing timeline for room".to_owned());
497 };
498 }
499
500 Char('L') => self.toggle_reaction_to_latest_msg().await,
501
502 Char('r') => self.details_mode = DetailsMode::ReadReceipts,
503 Char('t') => self.details_mode = DetailsMode::TimelineItems,
504 Char('e') => self.details_mode = DetailsMode::Events,
505 Char('l') => self.details_mode = DetailsMode::LinkedChunk,
506
507 Char('b')
508 if self.details_mode == DetailsMode::TimelineItems
509 || self.details_mode == DetailsMode::LinkedChunk =>
510 {
511 self.back_paginate();
512 }
513
514 Char('m') if self.details_mode == DetailsMode::ReadReceipts => {
515 self.mark_as_read().await
516 }
517
518 _ => {}
519 }
520 }
521 }
522 }
523 }
524 }
525
526 async fn run(&mut self, terminal: Terminal<impl Backend>) -> Result<()> {
527 self.render_loop(terminal).await?;
528
529 ratatui::restore();
531
532 println!("Stopping the sync service...");
533
534 self.sync_service.stop().await;
535 self.listen_task.abort();
536
537 for timeline in self.timelines.lock().unwrap().values() {
538 timeline.task.abort();
539 }
540
541 println!("okthxbye!");
542
543 Ok(())
544 }
545}
546
547impl Widget for &mut App {
548 fn render(self, area: Rect, buf: &mut Buffer) {
550 let vertical =
552 Layout::vertical([Constraint::Length(2), Constraint::Min(0), Constraint::Length(2)]);
553 let [header_area, rest_area, footer_area] = vertical.areas(area);
554
555 let horizontal =
558 Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]);
559 let [lhs, rhs] = horizontal.areas(rest_area);
560
561 self.render_title(header_area, buf);
562 self.render_left(lhs, buf);
563 self.render_right(rhs, buf);
564 self.render_footer(footer_area, buf);
565 }
566}
567
568impl App {
569 fn render_title(&self, area: Rect, buf: &mut Buffer) {
571 Paragraph::new("Multiverse").bold().centered().render(area, buf);
572 }
573
574 fn render_left(&mut self, area: Rect, buf: &mut Buffer) {
576 let outer_block = Block::default()
579 .borders(Borders::NONE)
580 .fg(TEXT_COLOR)
581 .bg(HEADER_BG)
582 .title("Room list")
583 .title_alignment(Alignment::Center);
584 let inner_block =
585 Block::default().borders(Borders::NONE).fg(TEXT_COLOR).bg(NORMAL_ROW_COLOR);
586
587 let outer_area = area;
590 let inner_area = outer_block.inner(outer_area);
591
592 outer_block.render(outer_area, buf);
594
595 let mut room_info = self.room_info.lock().unwrap().clone();
598
599 let items: Vec<ListItem<'_>> = self
601 .room_list_rooms
602 .items
603 .lock()
604 .unwrap()
605 .iter()
606 .enumerate()
607 .map(|(i, room)| {
608 let bg_color = match i % 2 {
609 0 => NORMAL_ROW_COLOR,
610 _ => ALT_ROW_COLOR,
611 };
612
613 let line = {
614 let room_id = room.room_id();
615 let room_info = room_info.remove(room_id);
616
617 let (raw, display, is_dm) = if let Some(info) = room_info {
618 (info.raw_name, info.display_name, info.is_dm)
619 } else {
620 (None, None, None)
621 };
622
623 let dm_marker = if is_dm.unwrap_or(false) { "🤫" } else { "" };
624
625 let room_name = if let Some(n) = display {
626 format!("{n} ({room_id})")
627 } else if let Some(n) = raw {
628 format!("m.room.name:{n} ({room_id})")
629 } else {
630 room_id.to_string()
631 };
632
633 format!("#{i}{dm_marker} {}", room_name)
634 };
635
636 let line = Line::styled(line, TEXT_COLOR);
637 ListItem::new(line).bg(bg_color)
638 })
639 .collect();
640
641 let items = List::new(items)
643 .block(inner_block)
644 .highlight_style(
645 Style::default()
646 .add_modifier(Modifier::BOLD)
647 .add_modifier(Modifier::REVERSED)
648 .fg(SELECTED_STYLE_FG),
649 )
650 .highlight_symbol(">")
651 .highlight_spacing(HighlightSpacing::Always);
652
653 StatefulWidget::render(items, inner_area, buf, &mut self.room_list_rooms.state);
654 }
655
656 fn render_right(&mut self, area: Rect, buf: &mut Buffer) {
659 let outer_block = Block::default()
663 .borders(Borders::NONE)
664 .fg(TEXT_COLOR)
665 .bg(HEADER_BG)
666 .title("Room view")
667 .title_alignment(Alignment::Center);
668 let inner_block = Block::default()
669 .borders(Borders::NONE)
670 .bg(NORMAL_ROW_COLOR)
671 .padding(Padding::horizontal(1));
672
673 let outer_area = area;
676 let inner_area = outer_block.inner(outer_area);
677
678 outer_block.render(outer_area, buf);
680
681 let render_paragraph = |buf: &mut Buffer, content: String| {
683 Paragraph::new(content)
684 .block(inner_block.clone())
685 .fg(TEXT_COLOR)
686 .wrap(Wrap { trim: false })
687 .render(inner_area, buf);
688 };
689
690 if let Some(room_id) = self.get_selected_room_id(None) {
691 match self.details_mode {
692 DetailsMode::ReadReceipts => {
693 match self.ui_rooms.lock().unwrap().get(&room_id).cloned() {
696 Some(room) => {
697 let receipts = room.read_receipts();
698 render_paragraph(
699 buf,
700 format!(
701 r#"Read receipts:
702- unread: {}
703- notifications: {}
704- mentions: {}
705
706---
707
708{:?}
709"#,
710 receipts.num_unread,
711 receipts.num_notifications,
712 receipts.num_mentions,
713 receipts
714 ),
715 )
716 }
717 None => render_paragraph(
718 buf,
719 "(room disappeared in the room list service)".to_owned(),
720 ),
721 }
722 }
723
724 DetailsMode::TimelineItems => {
725 if !self.render_timeline(&room_id, inner_block.clone(), inner_area, buf) {
726 render_paragraph(buf, "(room's timeline disappeared)".to_owned())
727 }
728 }
729
730 DetailsMode::LinkedChunk => {
731 match self.ui_rooms.lock().unwrap().get(&room_id).cloned() {
733 Some(room) => {
734 let lines = tokio::task::block_in_place(|| {
735 Handle::current().block_on(async {
736 let (cache, _drop_guards) = room
737 .event_cache()
738 .await
739 .expect("no event cache for that room");
740 cache.debug_string().await
741 })
742 });
743 render_paragraph(buf, lines.join("\n"));
744 }
745
746 None => render_paragraph(
747 buf,
748 "(room disappeared in the room list service)".to_owned(),
749 ),
750 }
751 }
752
753 DetailsMode::Events => match self.ui_rooms.lock().unwrap().get(&room_id).cloned() {
754 Some(room) => {
755 let events = tokio::task::block_in_place(|| {
756 Handle::current().block_on(async {
757 let (room_event_cache, _drop_handles) =
758 room.event_cache().await.unwrap();
759 let (events, _) = room_event_cache.subscribe().await;
760 events
761 })
762 });
763
764 let rendered_events = events
765 .into_iter()
766 .map(|sync_timeline_item| sync_timeline_item.raw().json().to_string())
767 .collect::<Vec<_>>()
768 .join("\n\n");
769
770 render_paragraph(buf, format!("Events:\n\n{rendered_events}"))
771 }
772
773 None => render_paragraph(
774 buf,
775 "(room disappeared in the room list service)".to_owned(),
776 ),
777 },
778 }
779 } else {
780 render_paragraph(buf, "Nothing to see here...".to_owned())
781 };
782 }
783
784 fn render_timeline(
786 &mut self,
787 room_id: &RoomId,
788 inner_block: Block<'_>,
789 inner_area: Rect,
790 buf: &mut Buffer,
791 ) -> bool {
792 let Some(items) =
793 self.timelines.lock().unwrap().get(room_id).map(|timeline| timeline.items.clone())
794 else {
795 return false;
796 };
797
798 let items = items.lock().unwrap();
799 let mut content = Vec::new();
800
801 for item in items.iter() {
802 match item.kind() {
803 TimelineItemKind::Event(ev) => {
804 let sender = ev.sender();
805
806 match ev.content() {
807 TimelineItemContent::Message(message) => {
808 if let MessageType::Text(text) = message.msgtype() {
809 content.push(format!("{}: {}", sender, text.body))
810 }
811 }
812
813 TimelineItemContent::RedactedMessage => {
814 content.push(format!("{}: -- redacted --", sender))
815 }
816 TimelineItemContent::UnableToDecrypt(_) => {
817 content.push(format!("{}: (UTD)", sender))
818 }
819 TimelineItemContent::Sticker(_)
820 | TimelineItemContent::MembershipChange(_)
821 | TimelineItemContent::ProfileChange(_)
822 | TimelineItemContent::OtherState(_)
823 | TimelineItemContent::FailedToParseMessageLike { .. }
824 | TimelineItemContent::FailedToParseState { .. }
825 | TimelineItemContent::Poll(_)
826 | TimelineItemContent::CallInvite
827 | TimelineItemContent::CallNotify => {
828 continue;
829 }
830 }
831 }
832
833 TimelineItemKind::Virtual(virt) => match virt {
834 VirtualTimelineItem::DateDivider(unix_ts) => {
835 content.push(format!("Date: {unix_ts:?}"));
836 }
837 VirtualTimelineItem::ReadMarker => {
838 content.push("Read marker".to_owned());
839 }
840 },
841 }
842 }
843
844 let list_items = content
845 .into_iter()
846 .enumerate()
847 .map(|(i, line)| {
848 let bg_color = match i % 2 {
849 0 => NORMAL_ROW_COLOR,
850 _ => ALT_ROW_COLOR,
851 };
852 let line = Line::styled(line, TEXT_COLOR);
853 ListItem::new(line).bg(bg_color)
854 })
855 .collect::<Vec<_>>();
856
857 let list = List::new(list_items)
858 .block(inner_block)
859 .highlight_style(
860 Style::default()
861 .add_modifier(Modifier::BOLD)
862 .add_modifier(Modifier::REVERSED)
863 .fg(SELECTED_STYLE_FG),
864 )
865 .highlight_symbol(">")
866 .highlight_spacing(HighlightSpacing::Always);
867
868 let mut dummy_list_state = ListState::default();
869 StatefulWidget::render(list, inner_area, buf, &mut dummy_list_state);
870 true
871 }
872
873 fn render_footer(&self, area: Rect, buf: &mut Buffer) {
876 let content = if let Some(status_message) = self.last_status_message.lock().unwrap().clone()
877 {
878 status_message
879 } else {
880 match self.details_mode {
881 DetailsMode::ReadReceipts => {
882 "\nUse j/k to move, s/S to start/stop the sync service, m to mark as read, t to show the timeline, e to show events.".to_owned()
883 }
884 DetailsMode::TimelineItems => {
885 "\nUse j/k to move, s/S to start/stop the sync service, r to show read receipts, e to show events, Q to enable/disable the send queue, M to send a message, L to like the last message.".to_owned()
886 }
887 DetailsMode::Events => {
888 "\nUse j/k to move, s/S to start/stop the sync service, r to show read receipts, t to show the timeline".to_owned()
889 }
890 DetailsMode::LinkedChunk => {
891 "\nUse j/k to move, s/S to start/stop the sync service, r to show read receipts, t to show the timeline, e to show events".to_owned()
892 }
893 }
894 };
895 Paragraph::new(content).centered().render(area, buf);
896 }
897}
898
899impl<T> StatefulList<T> {
900 fn next(&mut self) -> Option<usize> {
904 let num_items = self.items.lock().unwrap().len();
905
906 if num_items == 0 {
908 self.state.select(None);
909 return None;
910 }
911
912 let prev = self.state.selected();
914 let new = prev.map_or(0, |i| if i >= num_items - 1 { 0 } else { i + 1 });
915
916 if prev != Some(new) {
917 self.state.select(Some(new));
918 Some(new)
919 } else {
920 None
921 }
922 }
923
924 fn previous(&mut self) -> Option<usize> {
928 let num_items = self.items.lock().unwrap().len();
929
930 if num_items == 0 {
932 self.state.select(None);
933 return None;
934 }
935
936 let prev = self.state.selected();
938 let new = prev.map_or(0, |i| if i == 0 { num_items - 1 } else { i - 1 });
939
940 if prev != Some(new) {
941 self.state.select(Some(new));
942 Some(new)
943 } else {
944 None
945 }
946 }
947}
948
949async fn configure_client(cli: Cli) -> Result<Client> {
953 let Cli { server_name, session_path, proxy } = cli;
954
955 let mut client_builder = Client::builder()
956 .store_config(
957 StoreConfig::new("multiverse".to_owned())
958 .crypto_store(SqliteCryptoStore::open(session_path.join("crypto"), None).await?)
959 .state_store(SqliteStateStore::open(session_path.join("state"), None).await?)
960 .event_cache_store(
961 SqliteEventCacheStore::open(session_path.join("cache"), None).await?,
962 ),
963 )
964 .server_name(&server_name)
965 .with_encryption_settings(EncryptionSettings {
966 auto_enable_cross_signing: true,
967 backup_download_strategy: BackupDownloadStrategy::AfterDecryptionFailure,
968 auto_enable_backups: true,
969 });
970
971 if let Some(proxy_url) = proxy {
972 client_builder = client_builder.proxy(proxy_url).disable_ssl_verification();
973 }
974
975 let client = client_builder.build().await?;
976
977 log_in_or_restore_session(&client, &session_path).await?;
979
980 Ok(client)
981}
982
983async fn log_in_or_restore_session(client: &Client, session_path: &Path) -> Result<()> {
984 let session_path = session_path.join("session.json");
985
986 if let Ok(serialized) = std::fs::read_to_string(&session_path) {
987 let session: MatrixSession = serde_json::from_str(&serialized)?;
988 client.restore_session(session).await?;
989
990 println!("restored session");
991 } else {
992 login_with_password(client).await?;
993 println!("new login");
994
995 if let Some(session) = client.session() {
997 let AuthSession::Matrix(session) = session else { panic!("unexpected oidc session") };
998 let serialized = serde_json::to_string(&session)?;
999 std::fs::write(session_path, serialized)?;
1000
1001 println!("saved session");
1002 }
1003 }
1004
1005 Ok(())
1006}
1007
1008async fn login_with_password(client: &Client) -> Result<()> {
1011 println!("Logging in with username and password…");
1012
1013 loop {
1014 print!("\nUsername: ");
1015 stdout().flush().expect("Unable to write to stdout");
1016 let mut username = String::new();
1017 io::stdin().read_line(&mut username).expect("Unable to read user input");
1018 username = username.trim().to_owned();
1019
1020 let password = rpassword::prompt_password("Password.")?;
1021
1022 match client.matrix_auth().login_username(&username, password.trim()).await {
1023 Ok(_) => {
1024 println!("Logged in as {username}");
1025 break;
1026 }
1027 Err(error) => {
1028 println!("Error logging in: {error}");
1029 println!("Please try again\n");
1030 }
1031 }
1032 }
1033
1034 Ok(())
1035}