feat(multiverse): add a plain view of all the threads in a room

This commit is contained in:
Benjamin Bouvier
2025-04-22 16:59:10 +02:00
parent 3a97c61293
commit 6ebca8288c
3 changed files with 288 additions and 18 deletions

View File

@@ -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()

View File

@@ -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<JoinHandle<Result<Vec<TimelineEvent>, matrix_sdk::event_cache::EventCacheError>>>,
),
/// We've successfully loaded the list of threads, and this is the array of
/// the thread roots.
Loaded(Vec<TimelineEvent>),
/// 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<OwnedRoomId>,
}
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);
}
}

View File

@@ -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 => {}