diff --git a/labs/multiverse/src/widgets/room_view/details/mod.rs b/labs/multiverse/src/widgets/room_view/details/mod.rs index d88dec4cb..970a335ed 100644 --- a/labs/multiverse/src/widgets/room_view/details/mod.rs +++ b/labs/multiverse/src/widgets/room_view/details/mod.rs @@ -3,18 +3,43 @@ use matrix_sdk_ui::room_list_service::Room; use ratatui::{prelude::*, widgets::*}; use strum::{Display, EnumIter, FromRepr, IntoEnumIterator}; use style::palette::tailwind; +use threads::ThreadListLoader; -use self::{events::EventsView, linked_chunk::LinkedChunkView, read_receipts::ReadReceipts}; +use self::{ + events::EventsView, linked_chunk::LinkedChunkView, read_receipts::ReadReceipts, + threads::ThreadsView, +}; use crate::widgets::recovery::ShouldExit; mod events; mod linked_chunk; mod read_receipts; +mod threads; -#[derive(Clone, Copy, Default, Display, FromRepr, EnumIter)] +struct SelectedTabState { + /// Which tab is currently selected. + selected_tab: SelectedTab, + + kind: SelectedTabStateKind, +} + +impl Default for SelectedTabState { + fn default() -> Self { + Self { selected_tab: SelectedTab::Events, kind: SelectedTabStateKind::Empty } + } +} + +#[derive(Default)] +enum SelectedTabStateKind { + #[default] + Empty, + + Threads(ThreadListLoader), +} + +#[derive(Clone, Copy, Display, FromRepr, EnumIter)] enum SelectedTab { /// Show the raw event sources of the timeline. - #[default] Events, /// Show details about read receipts of the room. @@ -22,6 +47,9 @@ enum SelectedTab { /// Show the linked chunks that are used to display the timeline. LinkedChunks, + + /// Show the list of threads in this room. + Threads, } impl SelectedTab { @@ -45,7 +73,7 @@ impl SelectedTab { fn cycle_next(self) -> Self { let current_index = self as usize; let next_index = current_index.saturating_add(1); - Self::from_repr(next_index).unwrap_or_default() + Self::from_repr(next_index).unwrap_or(SelectedTab::Events) } /// Cycle to the previous tab, if we're at the first tab we return the last @@ -71,18 +99,19 @@ impl SelectedTab { Self::Events => tailwind::BLUE, Self::ReadReceipts => tailwind::EMERALD, Self::LinkedChunks => tailwind::INDIGO, + Self::Threads => tailwind::PURPLE, } } } -impl<'a> StatefulWidget for &'a SelectedTab { +impl<'a> StatefulWidget for &'a mut SelectedTabState { type State = Option<&'a Room>; fn render(self, area: Rect, buf: &mut Buffer, room: &mut Self::State) where Self: Sized, { - match self { + match self.selected_tab { SelectedTab::Events => { EventsView::new(room.as_deref()).render(area, buf); } @@ -90,32 +119,66 @@ impl<'a> StatefulWidget for &'a SelectedTab { ReadReceipts::new(room.as_deref()).render(area, buf); } SelectedTab::LinkedChunks => LinkedChunkView::new(room.as_deref()).render(area, buf), + SelectedTab::Threads => { + let loader = match &mut self.kind { + SelectedTabStateKind::Empty => panic!("unexpected state for the threads view"), + SelectedTabStateKind::Threads(loader) => loader, + }; + loader.init_if_needed(*room); + ThreadsView::new().render(area, buf, loader) + } } } } #[derive(Default)] pub struct RoomDetails { - selected_tab: SelectedTab, + state: SelectedTabState, } impl RoomDetails { /// Create a new [`RoomDetails`] struct with the [`SelectedTab::Events`] as /// the selected tab. pub fn with_events_as_selected() -> Self { - Self { selected_tab: SelectedTab::Events } + Self { + state: SelectedTabState { + selected_tab: SelectedTab::Events, + kind: SelectedTabStateKind::Empty, + }, + } } /// Create a new [`RoomDetails`] struct with the /// [`SelectedTab::ReadReceipts`] as the selected tab. pub fn with_receipts_as_selected() -> Self { - Self { selected_tab: SelectedTab::ReadReceipts } + Self { + state: SelectedTabState { + selected_tab: SelectedTab::ReadReceipts, + kind: SelectedTabStateKind::Empty, + }, + } } /// Create a new [`RoomDetails`] struct with the /// [`SelectedTab::LinkedChunks`] as the selected tab. pub fn with_chunks_as_selected() -> Self { - Self { selected_tab: SelectedTab::LinkedChunks } + Self { + state: SelectedTabState { + selected_tab: SelectedTab::LinkedChunks, + kind: SelectedTabStateKind::Empty, + }, + } + } + + /// Create a new [`RoomDetails`] struct with the + /// [`SelectedTab::Threads`] as the selected tab. + pub fn with_threads_as_selected() -> Self { + Self { + state: SelectedTabState { + selected_tab: SelectedTab::Threads, + kind: SelectedTabStateKind::Threads(ThreadListLoader::default()), + }, + } } pub fn handle_key_press(&mut self, event: KeyEvent) -> ShouldExit { @@ -152,27 +215,42 @@ impl RoomDetails { } } + fn sync_state(&mut self) { + match self.state.selected_tab { + SelectedTab::Events | SelectedTab::ReadReceipts | SelectedTab::LinkedChunks => { + self.state.kind = SelectedTabStateKind::Empty; + } + SelectedTab::Threads => { + self.state.kind = SelectedTabStateKind::Threads(ThreadListLoader::default()); + } + } + } + fn cycle_next_tab(&mut self) { - self.selected_tab = self.selected_tab.cycle_next(); + self.state.selected_tab = self.state.selected_tab.cycle_next(); + self.sync_state(); } fn cycle_prev_tab(&mut self) { - self.selected_tab = self.selected_tab.cycle_prev(); + self.state.selected_tab = self.state.selected_tab.cycle_prev(); + self.sync_state(); } fn next_tab(&mut self) { - self.selected_tab = self.selected_tab.next(); + self.state.selected_tab = self.state.selected_tab.next(); + self.sync_state(); } fn previous_tab(&mut self) { - self.selected_tab = self.selected_tab.previous(); + self.state.selected_tab = self.state.selected_tab.previous(); + self.sync_state(); } } impl<'a> StatefulWidget for &'a mut RoomDetails { type State = Option<&'a Room>; - fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) + fn render(self, area: Rect, buf: &mut Buffer, room: &mut Self::State) where Self: Sized, { @@ -192,8 +270,8 @@ impl<'a> StatefulWidget for &'a mut RoomDetails { .render(inner_area, buf); let titles = SelectedTab::iter().map(SelectedTab::title); - let highlight_style = (Color::default(), self.selected_tab.palette().c700); - let selected_tab_index = self.selected_tab as usize; + let highlight_style = (Color::default(), self.state.selected_tab.palette().c700); + let selected_tab_index = self.state.selected_tab as usize; let tabs_area = inner_area.inner(Margin::new(1, 1)); @@ -204,7 +282,7 @@ impl<'a> StatefulWidget for &'a mut RoomDetails { .divider(" ") .render(tab_title_area, buf); - self.selected_tab.render(tabs_area, buf, state); + self.state.render(tabs_area, buf, room); Line::raw("◄ ► to change tab | Press q to exit the details screen") .centered() diff --git a/labs/multiverse/src/widgets/room_view/details/threads.rs b/labs/multiverse/src/widgets/room_view/details/threads.rs new file mode 100644 index 000000000..b18aa8d18 --- /dev/null +++ b/labs/multiverse/src/widgets/room_view/details/threads.rs @@ -0,0 +1,174 @@ +use itertools::Itertools; +use matrix_sdk::{ + deserialized_responses::TimelineEvent, event_cache::ThreadSummary, ruma::OwnedRoomId, +}; +use matrix_sdk_ui::room_list_service::Room; +use ratatui::{ + prelude::*, + widgets::{Paragraph, Wrap}, +}; +use tokio::{runtime::Handle, task::JoinHandle}; + +use crate::TEXT_COLOR; + +/// Small state machine representing the loading of the thread list for a given +/// room. +#[derive(Default)] +enum ThreadListLoaderState { + /// The thread loaded is inactive; it hasn't been initialized yet, because + /// it hasn't seen a room yet, or it's in the process of being reset. + #[default] + Empty, + + /// We've started a task to load threads, but it hasn't finished yet. + Loading( + Option, matrix_sdk::event_cache::EventCacheError>>>, + ), + + /// We've successfully loaded the list of threads, and this is the array of + /// the thread roots. + Loaded(Vec), + + /// We've ran into an error while loading the list of threads. + Error(String), +} + +impl ThreadListLoaderState { + fn reset(&mut self) { + match self { + ThreadListLoaderState::Loading(task) => { + if let Some(task) = task.take() { + // Cancel the task if it's still running. + task.abort(); + } + } + _ => {} + } + + *self = Self::default(); + } +} + +#[derive(Default)] +pub struct ThreadListLoader { + state: ThreadListLoaderState, + room: Option, +} + +impl ThreadListLoader { + /// Start the task loading the threads root for this room, if it hasn't + /// started yet. + /// + /// If the room id is different from the previous one we had, restart the + /// local state. + pub fn init_if_needed(&mut self, room: Option<&Room>) { + if let Some(room) = room { + // If we've switched room, reinitialize the thread loader. + if self.room.as_deref() != Some(room.room_id()) { + self.state.reset(); + self.room = Some(room.room_id().to_owned()); + } + + // If the thread loader was inactive, kick off a task to initialize it. + if let ThreadListLoaderState::Empty = self.state { + let room = room.clone(); + let task = tokio::task::spawn(async move { + let (room_event_cache, _drop_handles) = room.event_cache().await.unwrap(); + room_event_cache.list_all_threads().await + }); + self.state = ThreadListLoaderState::Loading(Some(task)) + } + } + } +} + +#[derive(Default)] +pub struct ThreadsView; + +impl ThreadsView { + pub fn new() -> Self { + Self::default() + } +} + +impl StatefulWidget for &mut ThreadsView { + type State = ThreadListLoader; + + fn render(self, area: Rect, buf: &mut Buffer, loader: &mut Self::State) + where + Self: Sized, + { + let events = { + match &mut loader.state { + ThreadListLoaderState::Empty => { + Paragraph::new("Waiting for the task to initialize…") + .fg(TEXT_COLOR) + .render(area, buf); + return; + } + + ThreadListLoaderState::Loading(task) => { + if task.as_ref().is_some_and(|t| t.is_finished()) { + // The task is finished: awaiting it will immediately complete. + let task = task.take().unwrap(); + let events = + tokio::task::block_in_place(|| Handle::current().block_on(task)); + + let events = events.expect("list_all_threads task failed to join"); + + match events { + Ok(events) => { + loader.state = ThreadListLoaderState::Loaded(events.clone()); + } + Err(e) => { + loader.state = ThreadListLoaderState::Error(format!( + "Error listing threads: {}", + e + )); + return; + } + } + } + + Paragraph::new("Loading threads…").fg(TEXT_COLOR).render(area, buf); + return; + } + + ThreadListLoaderState::Error(ref error) => { + Paragraph::new(error.as_str()) + .fg(TEXT_COLOR) + .wrap(Wrap { trim: false }) + .render(area, buf); + return; + } + + ThreadListLoaderState::Loaded(ref events) => events.clone(), + } + }; + + if events.is_empty() { + Paragraph::new("No threads found").fg(TEXT_COLOR).render(area, buf); + return; + } + + let separator = Line::from("\n"); + let events = events + .into_iter() + .map(|ev| { + let (summary, _latest_event) = ThreadSummary::extract_from_bundled(ev.raw()) + .expect("all threads must have a summary!"); + format!( + "- Thread: root={}, count={}, latest_event_id={}", + ev.event_id().unwrap(), + summary.count, + summary.latest_event, + ) + }) + .map(Line::from); + + let events = Itertools::intersperse(events, separator); + let lines: Vec<_> = [Line::from("")].into_iter().chain(events).collect(); + + Paragraph::new(lines).fg(TEXT_COLOR).wrap(Wrap { trim: false }).render(area, buf); + } +} diff --git a/labs/multiverse/src/widgets/room_view/mod.rs b/labs/multiverse/src/widgets/room_view/mod.rs index 558260415..be1076db0 100644 --- a/labs/multiverse/src/widgets/room_view/mod.rs +++ b/labs/multiverse/src/widgets/room_view/mod.rs @@ -125,6 +125,16 @@ impl RoomView { } } + // f like fhreads (t was already busy) + (KeyModifiers::ALT, Char('f')) => { + if self.selected_room.is_some() { + self.mode = Mode::Details { + tiling_direction: DEFAULT_TILING_DIRECTION, + view: RoomDetails::with_threads_as_selected(), + } + } + } + _ => self.input.handle_key_press(key), } } @@ -165,6 +175,14 @@ impl RoomView { } } + // f like fhreads (t was already busy) + (KeyModifiers::ALT, Char('f')) => { + self.mode = Mode::Details { + tiling_direction: *tiling_direction, + view: RoomDetails::with_threads_as_selected(), + } + } + _ => match view.handle_key_press(key) { ShouldExit::No => {} ShouldExit::OnlySubScreen => {}