mirror of
https://github.com/matrix-org/matrix-rust-sdk.git
synced 2026-06-17 20:58:27 -04:00
feat(multiverse): add a plain view of all the threads in a room
This commit is contained in:
@@ -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()
|
||||
|
||||
174
labs/multiverse/src/widgets/room_view/details/threads.rs
Normal file
174
labs/multiverse/src/widgets/room_view/details/threads.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 => {}
|
||||
|
||||
Reference in New Issue
Block a user