multiverse/
main.rs

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    /// The homeserver the client should connect to.
48    server_name: OwnedServerName,
49
50    /// The path where session specific data should be stored.
51    #[clap(default_value = "/tmp/")]
52    session_path: PathBuf,
53
54    /// Set the proxy that should be used for the connection.
55    #[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/// Extra room information, like its display name, etc.
106#[derive(Clone)]
107struct ExtraRoomInfo {
108    /// Content of the raw m.room.name event, if available.
109    raw_name: Option<String>,
110
111    /// Calculated display name for the room.
112    display_name: Option<String>,
113
114    /// Is the room a DM?
115    is_dm: Option<bool>,
116}
117
118struct App {
119    /// Reference to the main SDK client.
120    client: Client,
121
122    /// The sync service used for synchronizing events.
123    sync_service: Arc<SyncService>,
124
125    /// Room list service rooms known to the app.
126    ui_rooms: Arc<Mutex<HashMap<OwnedRoomId, room_list_service::Room>>>,
127
128    /// Timelines data structures for each room.
129    timelines: Arc<Mutex<HashMap<OwnedRoomId, Timeline>>>,
130
131    /// Ratatui's list of room list rooms.
132    room_list_rooms: StatefulList<room_list_service::Room>,
133
134    /// Extra information about rooms.
135    room_info: Arc<Mutex<HashMap<OwnedRoomId, ExtraRoomInfo>>>,
136
137    /// Task listening to room list service changes, and spawning timelines.
138    listen_task: JoinHandle<()>,
139
140    /// Content of the latest status message, if set.
141    last_status_message: Arc<Mutex<Option<String>>>,
142
143    /// A task to automatically clear the status message in N seconds, if set.
144    clear_status_message: Option<JoinHandle<()>>,
145
146    /// What's shown in the details view, aka the right panel.
147    details_mode: DetailsMode,
148
149    /// The current room that's subscribed to in the room list's sliding sync.
150    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                    // Apply the diffs to the list of room entries.
188                    let mut rooms = rooms.lock().unwrap();
189
190                    for diff in diffs {
191                        diff.apply(&mut rooms);
192                    }
193
194                    // Collect rooms early to release the room entries list lock.
195                    (*rooms).clone()
196                };
197
198                // Clone the previous set of ui rooms to avoid keeping the ui_rooms lock (which
199                // we couldn't do below, because it's a sync lock, and has to be
200                // sync b/o rendering; and we'd have to cross await points
201                // below).
202                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                // Update all the room info for all rooms.
208                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                // Initialize all the new rooms.
225                for ui_room in all_rooms
226                    .into_iter()
227                    .filter(|room| !previous_ui_rooms.contains_key(room.room_id()))
228                {
229                    // Initialize the timeline.
230                    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                    // Save the timeline in the cache.
244                    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                    // Spawn a timeline task that will listen to all the timeline item changes.
249                    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                    // Save the room list service room in the cache.
268                    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        // This will sync (with encryption) until an error happens or the program is
277        // stopped.
278        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    /// Set the current status message (displayed at the bottom), for a few
299    /// seconds.
300    fn set_status_message(&mut self, status: String) {
301        if let Some(handle) = self.clear_status_message.take() {
302            // Cancel the previous task to clear the status message.
303            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            // Clear the status message in 4 seconds.
311            sleep(Duration::from_secs(4)).await;
312
313            *message.lock().unwrap() = None;
314        }));
315    }
316
317    /// Mark the currently selected room as read.
318    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        // Mark as read!
328        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            // Look for the latest (most recent) room message.
352            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 found, send a reaction.
361            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    /// Run a small back-pagination (expect a batch of 20 events, continue until
377    /// we get 10 timeline items or hit the timeline start).
378    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        // Cancel the previous back-pagination, if any.
389        if let Some(prev) = pagination.take() {
390            prev.abort();
391        }
392
393        // Start a new one, request batches of 20 events, stop after 10 timeline items
394        // have been added.
395        *pagination = Some(spawn(async move {
396            if let Err(err) = sdk_timeline.paginate_backwards(20).await {
397                // TODO: would be nice to be able to set the status
398                // message remotely?
399                //self.set_status_message(format!(
400                //"Error during backpagination: {err}"
401                //));
402                error!("Error during backpagination: {err}")
403            }
404        }));
405    }
406
407    /// Returns the currently selected room id, if any.
408    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        // Cancel the subscription to the previous room, if any.
422        self.current_room_subscription.take();
423
424        // Subscribe to the new room.
425        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        // At this point the user has exited the loop, so shut down the application.
530        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    /// Render the whole app.
549    fn render(self, area: Rect, buf: &mut Buffer) {
550        // Create a space for header, todo list and the footer.
551        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        // Create two chunks with equal horizontal screen space. One for the list and
556        // the other for the info block.
557        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    /// Render the top square (title of the program).
570    fn render_title(&self, area: Rect, buf: &mut Buffer) {
571        Paragraph::new("Multiverse").bold().centered().render(area, buf);
572    }
573
574    /// Renders the left part of the screen, that is, the list of rooms.
575    fn render_left(&mut self, area: Rect, buf: &mut Buffer) {
576        // We create two blocks, one is for the header (outer) and the other is for list
577        // (inner).
578        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        // We get the inner area from outer_block. We'll use this area later to render
588        // the table.
589        let outer_area = area;
590        let inner_area = outer_block.inner(outer_area);
591
592        // We can render the header in outer_area.
593        outer_block.render(outer_area, buf);
594
595        // Don't keep this lock too long by cloning the content. RAM's free these days,
596        // right?
597        let mut room_info = self.room_info.lock().unwrap().clone();
598
599        // Iterate through all elements in the `items` and stylize them.
600        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        // Create a List from all list items and highlight the currently selected one.
642        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    /// Render the right part of the screen, showing the details of the current
657    /// view.
658    fn render_right(&mut self, area: Rect, buf: &mut Buffer) {
659        // Split the block into two parts:
660        // - outer_block with the title of the block.
661        // - inner_block that will contain the actual details.
662        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        // This is a similar process to what we did for list. outer_info_area will be
674        // used for header inner_info_area will be used for the list info.
675        let outer_area = area;
676        let inner_area = outer_block.inner(outer_area);
677
678        // We can render the header. Inner area will be rendered later.
679        outer_block.render(outer_area, buf);
680
681        // Helper to render some string as a paragraph.
682        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                    // In read receipts mode, show the read receipts object as computed
694                    // by the client.
695                    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                    // In linked chunk mode, show a rough representation of the chunks.
732                    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    /// Renders the list of timeline items for the given room.
785    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    /// Render the bottom part of the screen, with a status message if one is
874    /// set, or a default help message otherwise.
875    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    /// Focus the list on the next item, wraps around if needs be.
901    ///
902    /// Returns the index only if there was a meaningful change.
903    fn next(&mut self) -> Option<usize> {
904        let num_items = self.items.lock().unwrap().len();
905
906        // If there's no item to select, leave early.
907        if num_items == 0 {
908            self.state.select(None);
909            return None;
910        }
911
912        // Otherwise, select the next one or wrap around.
913        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    /// Focus the list on the previous item, wraps around if needs be.
925    ///
926    /// Returns the index only if there was a meaningful change.
927    fn previous(&mut self) -> Option<usize> {
928        let num_items = self.items.lock().unwrap().len();
929
930        // If there's no item to select, leave early.
931        if num_items == 0 {
932            self.state.select(None);
933            return None;
934        }
935
936        // Otherwise, select the previous one or wrap around.
937        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
949/// Configure the client so it's ready for sync'ing.
950///
951/// Will log in or reuse a previous session.
952async 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    // Try reading a session, otherwise create a new one.
978    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        // Immediately save the session to disk.
996        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
1008/// Asks the user of a username and password, and try to login using the matrix
1009/// auth with those.
1010async 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}