multiverse/widgets/room_view/
mod.rs

1use std::sync::Arc;
2
3use crossterm::event::{Event, KeyCode, KeyModifiers};
4use futures_util::StreamExt;
5use imbl::Vector;
6use input::MessageOrCommand;
7use invited_room::InvitedRoomView;
8use matrix_sdk::{
9    Client, Room, RoomState,
10    locks::Mutex,
11    ruma::{
12        OwnedEventId, OwnedRoomId, RoomId, UserId,
13        api::client::receipt::create_receipt::v3::ReceiptType,
14        events::room::message::RoomMessageEventContent,
15    },
16};
17use matrix_sdk_ui::{
18    Timeline,
19    timeline::{TimelineBuilder, TimelineFocus, TimelineItem},
20};
21use ratatui::{prelude::*, widgets::*};
22use tokio::{spawn, sync::OnceCell, task::JoinHandle};
23use tracing::info;
24
25use self::{details::RoomDetails, input::Input, timeline::TimelineView};
26use super::status::StatusHandle;
27use crate::{
28    HEADER_BG, NORMAL_ROW_COLOR, TEXT_COLOR, Timelines,
29    widgets::{recovery::ShouldExit, room_view::timeline::TimelineListState},
30};
31
32mod details;
33mod input;
34mod invited_room;
35mod timeline;
36
37const DEFAULT_TILING_DIRECTION: Direction = Direction::Horizontal;
38
39pub struct DetailsState<'a> {
40    selected_room: Option<&'a Room>,
41    selected_item: Option<Arc<TimelineItem>>,
42}
43
44enum Mode {
45    Normal { invited_room_view: Option<InvitedRoomView> },
46    Details { tiling_direction: Direction, view: RoomDetails },
47}
48
49enum TimelineKind {
50    Room {
51        room: Option<OwnedRoomId>,
52    },
53
54    Thread {
55        room: OwnedRoomId,
56        thread_root: OwnedEventId,
57        /// The threaded-focused timeline for this thread.
58        timeline: Arc<OnceCell<Arc<Timeline>>>,
59        /// Items in the thread timeline (to avoid recomputing them every single
60        /// time).
61        items: Arc<Mutex<Vector<Arc<TimelineItem>>>>,
62        /// Task listening to updates from the threaded timeline, to maintain
63        /// the `items` field over time.
64        task: JoinHandle<()>,
65    },
66}
67
68pub struct RoomView {
69    client: Client,
70
71    /// Timelines data structures for each room.
72    timelines: Timelines,
73
74    status_handle: StatusHandle,
75
76    current_pagination: Arc<Mutex<Option<JoinHandle<()>>>>,
77
78    mode: Mode,
79    kind: TimelineKind,
80
81    timeline_list: TimelineListState,
82
83    input: Input,
84}
85
86impl RoomView {
87    pub fn new(client: Client, timelines: Timelines, status_handle: StatusHandle) -> Self {
88        Self {
89            client,
90            timelines,
91            status_handle,
92            current_pagination: Default::default(),
93            mode: Mode::Normal { invited_room_view: None },
94            kind: TimelineKind::Room { room: None },
95            input: Input::new(),
96            timeline_list: TimelineListState::default(),
97        }
98    }
99
100    fn switch_to_room_timeline(&mut self, room: Option<OwnedRoomId>) {
101        match &mut self.kind {
102            TimelineKind::Room { room: prev_room } => {
103                self.kind = TimelineKind::Room { room: room.or(prev_room.take()) };
104            }
105            TimelineKind::Thread { task, room, .. } => {
106                // If we were in a thread, abort the task.
107                task.abort();
108                self.kind = TimelineKind::Room { room: Some(room.clone()) };
109            }
110        }
111    }
112
113    fn switch_to_thread_timeline(&mut self) {
114        let Some(room) = self.room() else {
115            return;
116        };
117
118        let Some(timeline_list_nth) = self.timeline_list.selected() else {
119            return;
120        };
121
122        let Some(items) = self.get_selected_timeline_items() else {
123            self.status_handle.set_message("missing timeline for room".to_owned());
124            return;
125        };
126
127        let Some(root_event) = items.get(timeline_list_nth).and_then(|item| item.as_event()) else {
128            self.status_handle.set_message("no event associated to this timeline item".to_owned());
129            return;
130        };
131
132        if root_event.content().as_message().is_none() {
133            self.status_handle.set_message("this event can't be a thread start!".to_owned());
134            return;
135        }
136
137        let Some(root_event_id) = root_event.event_id().map(ToOwned::to_owned) else {
138            self.status_handle.set_message("can't open thread on a local echo".to_owned());
139            return;
140        };
141
142        info!("Opening thread view for event {root_event_id} in room {}", room.room_id());
143
144        let thread_timeline = Arc::new(OnceCell::new());
145        let items = Arc::new(Mutex::new(Default::default()));
146
147        let i = items.clone();
148        let t = thread_timeline.clone();
149        let root = root_event_id;
150        let cloned_root = root.clone();
151        let r = room.clone();
152        let task = spawn(async move {
153            let timeline = TimelineBuilder::new(&r)
154                .with_focus(TimelineFocus::Thread { root_event_id: cloned_root })
155                .track_read_marker_and_receipts()
156                .build()
157                .await
158                .unwrap();
159
160            let items = i;
161            let (initial_items, mut stream) = timeline.subscribe().await;
162
163            t.set(Arc::new(timeline)).unwrap();
164            *items.lock() = initial_items;
165
166            while let Some(diffs) = stream.next().await {
167                let mut items = items.lock();
168                for diff in diffs {
169                    diff.apply(&mut items);
170                }
171            }
172        });
173
174        self.timeline_list.unselect();
175
176        self.kind = TimelineKind::Thread {
177            thread_root: root,
178            room: room.room_id().to_owned(),
179            timeline: thread_timeline,
180            items,
181            task,
182        };
183    }
184
185    fn room_id(&self) -> Option<&RoomId> {
186        match &self.kind {
187            TimelineKind::Room { room } => room.as_deref(),
188            TimelineKind::Thread { room, .. } => Some(room),
189        }
190    }
191
192    /// Get currently focused [`Room`]
193    pub fn room(&self) -> Option<Room> {
194        self.room_id().and_then(|room_id| self.client.get_room(room_id))
195    }
196
197    pub async fn handle_event(&mut self, event: Event) {
198        use KeyCode::*;
199
200        match &mut self.mode {
201            Mode::Normal { invited_room_view } => {
202                if let Some(view) = invited_room_view {
203                    view.handle_event(event);
204                } else if let Event::Key(key) = event {
205                    match (key.modifiers, key.code) {
206                        (KeyModifiers::NONE, Enter) => {
207                            if !self.input.is_empty() {
208                                let message_or_command = self.input.get_input();
209
210                                match message_or_command {
211                                    Ok(MessageOrCommand::Message(message)) => {
212                                        self.send_message(message).await
213                                    }
214                                    Ok(MessageOrCommand::Command(command)) => {
215                                        self.handle_command(command).await
216                                    }
217                                    Err(e) => {
218                                        self.status_handle.set_message(e.render().to_string());
219                                        self.input.clear();
220                                    }
221                                }
222                            }
223                        }
224
225                        // Pressing Escape on a threaded timeline will get back to the room
226                        // timeline.
227                        (KeyModifiers::NONE, Esc)
228                            if matches!(self.kind, TimelineKind::Thread { .. }) =>
229                        {
230                            self.switch_to_room_timeline(None);
231                        }
232
233                        // Pressing 'Alt+s' on a threaded timeline will print the current
234                        // subscription status.
235                        (KeyModifiers::ALT, Char('s')) => {
236                            self.print_thread_subscription_status().await;
237                        }
238
239                        (KeyModifiers::CONTROL, Char('l')) => {
240                            self.toggle_reaction_to_latest_msg().await
241                        }
242
243                        (KeyModifiers::NONE, PageUp) => self.back_paginate(),
244
245                        (KeyModifiers::ALT, Char('e')) => {
246                            if let TimelineKind::Room { room: Some(_) } = self.kind {
247                                self.mode = Mode::Details {
248                                    tiling_direction: DEFAULT_TILING_DIRECTION,
249                                    view: RoomDetails::with_events_as_selected(),
250                                }
251                            }
252                        }
253
254                        (KeyModifiers::ALT, Char('r')) => {
255                            if let TimelineKind::Room { room: Some(_) } = self.kind {
256                                self.mode = Mode::Details {
257                                    tiling_direction: DEFAULT_TILING_DIRECTION,
258                                    view: RoomDetails::with_receipts_as_selected(),
259                                }
260                            }
261                        }
262
263                        (KeyModifiers::ALT, Char('l')) => {
264                            if let TimelineKind::Room { room: Some(_) } = self.kind {
265                                self.mode = Mode::Details {
266                                    tiling_direction: DEFAULT_TILING_DIRECTION,
267                                    view: RoomDetails::with_chunks_as_selected(),
268                                }
269                            }
270                        }
271
272                        (_, Down) | (KeyModifiers::CONTROL, Char('n')) => {
273                            self.timeline_list.select_next()
274                        }
275                        (_, Up) | (KeyModifiers::CONTROL, Char('p')) => {
276                            self.timeline_list.select_previous()
277                        }
278                        (_, Esc) => self.timeline_list.unselect(),
279
280                        (KeyModifiers::CONTROL, Char('t'))
281                            if matches!(self.kind, TimelineKind::Room { .. }) =>
282                        {
283                            self.switch_to_thread_timeline();
284                        }
285
286                        _ => self.input.handle_key_press(key),
287                    }
288                }
289            }
290
291            Mode::Details { view, tiling_direction } => {
292                if let Event::Key(key) = event {
293                    match (key.modifiers, key.code) {
294                        (KeyModifiers::NONE, PageUp) => self.back_paginate(),
295
296                        (KeyModifiers::ALT, Char('t')) => {
297                            let new_layout = match tiling_direction {
298                                Direction::Horizontal => Direction::Vertical,
299                                Direction::Vertical => Direction::Horizontal,
300                            };
301
302                            *tiling_direction = new_layout;
303                        }
304
305                        (KeyModifiers::ALT, Char('e')) => {
306                            self.mode = Mode::Details {
307                                tiling_direction: *tiling_direction,
308                                view: RoomDetails::with_events_as_selected(),
309                            }
310                        }
311
312                        (KeyModifiers::ALT, Char('r')) => {
313                            self.mode = Mode::Details {
314                                tiling_direction: *tiling_direction,
315                                view: RoomDetails::with_receipts_as_selected(),
316                            }
317                        }
318
319                        (KeyModifiers::ALT, Char('l')) => {
320                            self.mode = Mode::Details {
321                                tiling_direction: *tiling_direction,
322                                view: RoomDetails::with_chunks_as_selected(),
323                            }
324                        }
325
326                        (_, Down) | (KeyModifiers::CONTROL, Char('n')) => {
327                            self.timeline_list.select_next()
328                        }
329
330                        (_, Up) | (KeyModifiers::CONTROL, Char('p')) => {
331                            self.timeline_list.select_previous()
332                        }
333
334                        _ => match view.handle_key_press(key) {
335                            ShouldExit::No => {}
336                            ShouldExit::OnlySubScreen => {}
337                            ShouldExit::Yes => self.mode = Mode::Normal { invited_room_view: None },
338                        },
339                    }
340                }
341            }
342        }
343    }
344
345    pub fn set_selected_room(&mut self, room_id: Option<OwnedRoomId>) {
346        if let Some(room_id) = room_id.as_deref() {
347            let maybe_room = self.client.get_room(room_id);
348
349            if let Some(room) = maybe_room {
350                self.switch_to_room_timeline(Some(room_id.to_owned()));
351
352                if matches!(room.state(), RoomState::Invited) {
353                    let view = InvitedRoomView::new(room);
354                    self.mode = Mode::Normal { invited_room_view: Some(view) };
355                } else {
356                    match &mut self.mode {
357                        Mode::Normal { invited_room_view } => {
358                            invited_room_view.take();
359                        }
360                        Mode::Details { .. } => {}
361                    }
362                }
363            }
364        }
365
366        self.timeline_list = TimelineListState::default();
367    }
368
369    fn get_selected_timeline(&self) -> Option<Arc<Timeline>> {
370        match &self.kind {
371            TimelineKind::Room { room } => room
372                .as_deref()
373                .and_then(|room_id| Some(self.timelines.lock().get(room_id)?.timeline.clone())),
374            TimelineKind::Thread { timeline, .. } => timeline.get().cloned(),
375        }
376    }
377
378    fn get_selected_timeline_items(&self) -> Option<Vector<Arc<TimelineItem>>> {
379        match &self.kind {
380            TimelineKind::Room { room } => room
381                .as_deref()
382                .and_then(|room_id| Some(self.timelines.lock().get(room_id)?.items.lock().clone())),
383            TimelineKind::Thread { items, .. } => Some(items.lock().clone()),
384        }
385    }
386
387    /// Run a small back-pagination (expect a batch of 20 events, continue until
388    /// we get 10 timeline items or hit the timeline start).
389    pub fn back_paginate(&mut self) {
390        let Some(sdk_timeline) = self.get_selected_timeline() else {
391            self.status_handle.set_message("missing timeline for room".to_owned());
392            return;
393        };
394
395        let mut pagination = self.current_pagination.lock();
396
397        // Cancel the previous back-pagination, if any.
398        if let Some(prev) = pagination.take() {
399            prev.abort();
400        }
401
402        let status_handle = self.status_handle.clone();
403
404        // Request to back-paginate 5 events.
405        *pagination = Some(spawn(async move {
406            if let Err(err) = sdk_timeline.paginate_backwards(5).await {
407                status_handle.set_message(format!("Error during backpagination: {err}"));
408            }
409        }));
410    }
411
412    pub async fn toggle_reaction_to_latest_msg(&mut self) {
413        let Some((sdk_timeline, items)) =
414            self.get_selected_timeline().zip(self.get_selected_timeline_items())
415        else {
416            self.status_handle.set_message("missing timeline for room".to_owned());
417            return;
418        };
419
420        // Look for the latest (most recent) room message.
421        let Some(item_id) = items.iter().rev().find_map(|it| {
422            let event_item = it.as_event()?;
423            event_item.content().as_message()?;
424            Some(event_item.identifier())
425        }) else {
426            self.status_handle.set_message("no item to react to".to_owned());
427            return;
428        };
429
430        // If found, send a reaction.
431        match sdk_timeline.toggle_reaction(&item_id, "🥰").await {
432            Ok(_) => {
433                self.status_handle.set_message("reaction sent!".to_owned());
434            }
435            Err(err) => self.status_handle.set_message(format!("error when reacting: {err}")),
436        }
437    }
438
439    /// Attempt to find the currently selected room and pass it to the async
440    /// callback.
441    async fn call_with_room(&self, function: impl AsyncFnOnce(Room, &StatusHandle)) {
442        if let Some(room) = self.room() {
443            function(room, &self.status_handle).await
444        } else {
445            self.status_handle
446                .set_message("Couldn't find a room selected room to perform an action".to_owned());
447        }
448    }
449
450    async fn invite_member(&mut self, user_id: &str) {
451        self.call_with_room(async move |room, status_handle| {
452            let user_id = match UserId::parse_with_server_name(
453                user_id,
454                room.client().user_id().unwrap().server_name(),
455            ) {
456                Ok(user_id) => user_id,
457                Err(e) => {
458                    status_handle
459                        .set_message(format!("Failed to parse {user_id} as a user ID: {e:?}"));
460                    return;
461                }
462            };
463
464            match room.invite_user_by_id(&user_id).await {
465                Ok(_) => {
466                    status_handle
467                        .set_message(format!("Successfully invited {user_id} to the room"));
468                }
469                Err(e) => {
470                    status_handle
471                        .set_message(format!("Failed to invite {user_id} to the room: {e:?}"));
472                }
473            }
474        })
475        .await;
476
477        self.input.clear();
478    }
479
480    async fn leave_room(&mut self) {
481        self.call_with_room(async |room, status_handle| {
482            let _ = room.leave().await.inspect_err(|e| {
483                status_handle.set_message(format!("Couldn't leave the room {e:?}"))
484            });
485        })
486        .await;
487
488        self.input.clear();
489    }
490
491    async fn subscribe_thread(&mut self) {
492        if let TimelineKind::Thread { thread_root, .. } = &self.kind {
493            self.call_with_room(async |room, status_handle| {
494                if let Err(err) = room.subscribe_thread(thread_root.clone(), None).await {
495                    status_handle.set_message(format!("error when subscribing to a thread: {err}"));
496                } else {
497                    status_handle.set_message("Subscribed to thread!".to_owned());
498                }
499            })
500            .await;
501
502            self.input.clear();
503        }
504    }
505
506    async fn unsubscribe_thread(&mut self) {
507        if let TimelineKind::Thread { thread_root, .. } = &self.kind {
508            self.call_with_room(async |room, status_handle| {
509                if let Err(err) = room.unsubscribe_thread(thread_root.clone()).await {
510                    status_handle
511                        .set_message(format!("error when unsubscribing to a thread: {err}"));
512                } else {
513                    status_handle.set_message("Unsubscribed from thread!".to_owned());
514                }
515            })
516            .await;
517
518            self.input.clear();
519        }
520    }
521
522    async fn print_thread_subscription_status(&mut self) {
523        if let TimelineKind::Thread { thread_root, .. } = &self.kind {
524            self.call_with_room(async |room, status_handle| {
525                match room.fetch_thread_subscription(thread_root.clone()).await {
526                    Ok(Some(subscription)) => {
527                        status_handle.set_message(format!(
528                            "Thread subscription status: {}",
529                            if subscription.automatic {
530                                "subscribed (automatic)"
531                            } else {
532                                "subscribed (manual)"
533                            }
534                        ));
535                    }
536                    Ok(None) => {
537                        status_handle
538                            .set_message("Thread is not subscribed or does not exist".to_owned());
539                    }
540                    Err(err) => {
541                        status_handle
542                            .set_message(format!("Error getting thread subscription: {err}"));
543                    }
544                }
545            })
546            .await;
547        }
548    }
549
550    async fn handle_command(&mut self, command: input::Command) {
551        match command {
552            input::Command::Invite { user_id } => self.invite_member(&user_id).await,
553            input::Command::Leave => self.leave_room().await,
554            input::Command::Subscribe => self.subscribe_thread().await,
555            input::Command::Unsubscribe => self.unsubscribe_thread().await,
556        }
557    }
558
559    async fn send_message(&mut self, message: String) {
560        if let Some(sdk_timeline) = self.get_selected_timeline() {
561            match sdk_timeline.send(RoomMessageEventContent::text_plain(message).into()).await {
562                Ok(_) => {
563                    self.input.clear();
564                }
565                Err(err) => {
566                    self.status_handle.set_message(format!("error when sending event: {err}"));
567                }
568            }
569        } else {
570            self.status_handle.set_message("missing timeline for room".to_owned());
571        }
572    }
573
574    /// Mark the currently selected room as read.
575    pub async fn mark_as_read(&mut self) {
576        let Some(sdk_timeline) = self.get_selected_timeline() else {
577            self.status_handle.set_message("missing timeline for room".to_owned());
578            return;
579        };
580
581        match sdk_timeline.mark_as_read(ReceiptType::Read).await {
582            Ok(did) => {
583                self.status_handle.set_message(format!(
584                    "did {}send a read receipt!",
585                    if did { "" } else { "not " }
586                ));
587            }
588            Err(err) => {
589                self.status_handle.set_message(format!("error when marking a room as read: {err}"));
590            }
591        }
592    }
593
594    fn get_selected_event(&self) -> Option<Arc<TimelineItem>> {
595        let selected = self.timeline_list.selected()?;
596        let items = self.get_selected_timeline_items()?;
597        items.get(selected).cloned()
598    }
599
600    fn update(&mut self) {
601        match &mut self.mode {
602            Mode::Normal { invited_room_view } => {
603                if let Some(view) = invited_room_view
604                    && view.should_switch()
605                {
606                    self.mode = Mode::Normal { invited_room_view: None };
607                }
608            }
609            Mode::Details { .. } => {}
610        }
611    }
612}
613
614impl Widget for &mut RoomView {
615    fn render(self, area: Rect, buf: &mut Buffer)
616    where
617        Self: Sized,
618    {
619        self.update();
620
621        // Create a space for the header, timeline, and input area.
622        let vertical =
623            Layout::vertical([Constraint::Length(1), Constraint::Min(0), Constraint::Length(1)]);
624        let [header_area, middle_area, input_area] = vertical.areas(area);
625
626        let is_thread_view = matches!(self.kind, TimelineKind::Thread { .. });
627        let title = if is_thread_view { "Thread view" } else { "Room view" };
628
629        let header_block = Block::default()
630            .borders(Borders::NONE)
631            .fg(TEXT_COLOR)
632            .bg(HEADER_BG)
633            .title(title)
634            .title_alignment(Alignment::Center);
635
636        let middle_block = Block::default()
637            .border_set(symbols::border::THICK)
638            .bg(NORMAL_ROW_COLOR)
639            .padding(Padding::horizontal(1));
640
641        // Let's render the backgrounds for the header and the timeline.
642        header_block.render(header_area, buf);
643        middle_block.render(middle_area, buf);
644
645        // Helper to render some string as a paragraph.
646        let render_paragraph = |buf: &mut Buffer, content: String| {
647            Paragraph::new(content)
648                .fg(TEXT_COLOR)
649                .wrap(Wrap { trim: false })
650                .render(middle_area, buf);
651        };
652
653        if let Some(room_id) = self.room_id() {
654            let maybe_room = self.client.get_room(room_id);
655            let mut maybe_room = maybe_room.as_ref();
656
657            let selected_event = self.get_selected_event();
658
659            let timeline_area = match &mut self.mode {
660                Mode::Normal { invited_room_view } => {
661                    if let Some(view) = invited_room_view {
662                        view.render(middle_area, buf);
663
664                        None
665                    } else {
666                        self.input.render(input_area, buf, &mut maybe_room);
667                        Some(middle_area)
668                    }
669                }
670
671                Mode::Details { tiling_direction, view } => {
672                    let vertical = Layout::new(
673                        *tiling_direction,
674                        [Constraint::Percentage(50), Constraint::Percentage(50)],
675                    );
676                    let [timeline_area, details_area] = vertical.areas(middle_area);
677                    Clear.render(details_area, buf);
678
679                    let mut state =
680                        DetailsState { selected_room: maybe_room, selected_item: selected_event };
681
682                    view.render(details_area, buf, &mut state);
683
684                    Some(timeline_area)
685                }
686            };
687
688            if let Some(timeline_area) = timeline_area
689                && let Some(items) = self.get_selected_timeline_items()
690            {
691                let is_thread = matches!(self.kind, TimelineKind::Thread { .. });
692                let mut timeline = TimelineView::new(&items, is_thread);
693                timeline.render(timeline_area, buf, &mut self.timeline_list);
694            }
695        } else {
696            render_paragraph(buf, "Nothing to see here...".to_owned())
697        }
698    }
699}